diff --git a/.ci/asciidoc-converter/build_document_pdf.bat b/.ci/asciidoc-converter/build_document_pdf.bat new file mode 100644 index 00000000..d245d581 --- /dev/null +++ b/.ci/asciidoc-converter/build_document_pdf.bat @@ -0,0 +1,3 @@ +gradlew.bat run --args="--input-file ../../asciidoc/sdpi-supplement.adoc --output-folder ../../sdpi-supplement --backend pdf" + + diff --git a/.ci/asciidoc-converter/build_test_result.bat b/.ci/asciidoc-converter/build_test_result.bat new file mode 100644 index 00000000..004506a3 --- /dev/null +++ b/.ci/asciidoc-converter/build_test_result.bat @@ -0,0 +1,7 @@ +:: Creates html output file based on a test ascii doc. +:: We leave off all the html headers to make the test more robust +:: (that is, test don't depend on css in headers). + +:: Argument: name of test ascii doc file in src/test/resources/ + +gradlew.bat run --args="--input-file src/test/resources/%1.adoc --output-folder src/test/resources --backend html --test" diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/AsciidocConverter.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/AsciidocConverter.kt index 24207b72..e85b22c7 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/AsciidocConverter.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/AsciidocConverter.kt @@ -6,73 +6,147 @@ import org.asciidoctor.Asciidoctor import org.asciidoctor.Options import org.asciidoctor.SafeMode import org.sdpi.asciidoc.extension.* -import org.sdpi.asciidoc.github.Issues import java.io.File import java.io.OutputStream import java.nio.file.Files import java.nio.file.Path +import kotlin.io.path.absolutePathString + +/** + * Options to configure AsciidocConverter. + */ +class ConverterOptions( + /** + * Token to access the GitHub api. For including issues + * in the document output, for example. + */ + val githubToken: String? = null, + + /** + * Defines the target format for the document output. See + * https://docs.asciidoctor.org/asciidoctorj/latest/asciidoctor-api-options/#backend + * Typically "html" or "pdf" + */ + val outputFormat: String = "html", + + /** + * When true, document structure is written to the log stream + * for diagnostics. + */ + val dumpStructure: Boolean = false, + + /** + * When true, the document output is simplified for unit + * tests. For example, the style sheet is not included. + */ + val generateTestOutput: Boolean = false, + + /** + * Folder where extracts (requirements, use-cases, etc.) should + * be placed. If null, the extracts won't be written. + */ + val extractsFolder: Path? = null, +) { + companion object { + private const val DEFAULT_EXTRACTS_FOLDER: String = "referenced-artifacts" + + fun makeDefaultPath(strOutputFolder: String): Path { + return Path.of(strOutputFolder, DEFAULT_EXTRACTS_FOLDER) + } + } +} + class AsciidocConverter( private val inputType: Input, - private val outputFile: File, - private val githubToken: String?, - private val mode: Mode = Mode.Productive, + private val outputFile: OutputStream, + private val conversionOptions: ConverterOptions, ) : Runnable { override fun run() { val options = Options.builder() .safe(SafeMode.UNSAFE) - .backend(BACKEND) + .backend(conversionOptions.outputFormat) .sourcemap(true) - .toFile(outputFile).build() + .headerFooter(!conversionOptions.generateTestOutput) + .toStream(outputFile).build() val asciidoctor = Asciidoctor.Factory.create() val anchorReplacements = AnchorReplacementsMap() - val requirementsBlockProcessor = RequirementsBlockProcessor() - asciidoctor.javaExtensionRegistry().block(requirementsBlockProcessor) - asciidoctor.javaExtensionRegistry().treeprocessor( - NumberingProcessor( - when (mode) { - is Mode.Test -> mode.structureDump - else -> null - }, - anchorReplacements - ) - ) - asciidoctor.javaExtensionRegistry().treeprocessor(RequirementLevelProcessor()) - asciidoctor.javaExtensionRegistry().preprocessor(IssuesSectionPreprocessor(githubToken)) + // Formats sdpi_requirement blocks & their content. + // * RequirementBlockProcessor2 handles the containing sdpi_requirement block + // * RelatedBlockProcessor handles [RELATED] blocks within requirement blocks. + // * RequirementExampleBlockProcessor handles [EXAMPLE] blocks within requirement blocks. + asciidoctor.javaExtensionRegistry().block(RequirementBlockProcessor2()) + asciidoctor.javaExtensionRegistry().block(RelatedBlockProcessor()) + asciidoctor.javaExtensionRegistry().block(RequirementExampleBlockProcessor()) + + asciidoctor.javaExtensionRegistry().treeprocessor(NumberingProcessor(null, anchorReplacements)) + + // Gather bibliography entries. + val bibliographyCollector = BibliographyCollector() + asciidoctor.javaExtensionRegistry().treeprocessor(bibliographyCollector) + + // Gather SDPI specific information from the document such as + // requirements and use-cases. + val infoCollector = TreeInfoCollector(bibliographyCollector) + asciidoctor.javaExtensionRegistry().treeprocessor(infoCollector) + + // Support to insert tables of requirements etc. sdpi_requirement_table macros. + // Block macro processors insert placeholders that are populated when the tree is ready. + // Tree processors fill in the placeholders. + asciidoctor.javaExtensionRegistry().blockMacro(AddRequirementQueryPlaceholder()) + asciidoctor.javaExtensionRegistry().blockMacro(AddICSPlaceholder()) + asciidoctor.javaExtensionRegistry().treeprocessor(PopulateTables(infoCollector.info())) + + // Handle inline macros to cross-reference information from the document tree. + asciidoctor.javaExtensionRegistry().inlineMacro(RequirementReferenceMacroProcessor(infoCollector.info())) + asciidoctor.javaExtensionRegistry().inlineMacro(UseCaseReferenceMacroProcessor(infoCollector.info())) + + asciidoctor.javaExtensionRegistry().preprocessor(IssuesSectionPreprocessor(conversionOptions.githubToken)) asciidoctor.javaExtensionRegistry().preprocessor(DisableSectNumsProcessor()) asciidoctor.javaExtensionRegistry().preprocessor(ReferenceSanitizerPreprocessor(anchorReplacements)) - asciidoctor.javaExtensionRegistry() - .postprocessor(ReferenceSanitizerPostprocessor(anchorReplacements)) + if (conversionOptions.outputFormat == "html") { + // Post processor not supported for PDFs + // https://docs.asciidoctor.org/asciidoctorj/latest/extensions/postprocessor/ + asciidoctor.javaExtensionRegistry().postprocessor(ReferenceSanitizerPostprocessor(anchorReplacements)) + } + + // Dumps tree of document structure to stdio. + // Best not to use for very large documents! + // Note: enabling this breaks variable replacement for {var_transaction_id}. Unclear why. + if (conversionOptions.dumpStructure) { + asciidoctor.javaExtensionRegistry().treeprocessor(DumpTreeInfo()) + } asciidoctor.requireLibrary("asciidoctor-diagram") // enables plantuml + when (inputType) { is Input.FileInput -> asciidoctor.convertFile(inputType.file, options) is Input.StringInput -> asciidoctor.convert(inputType.string, options) } - asciidoctor.shutdown() + if (conversionOptions.extractsFolder != null) { + val jsonFormatter = Json { prettyPrint = true } - val referencedArtifactsName = "referenced-artifacts" - val path = Path.of(outputFile.parentFile.absolutePath, referencedArtifactsName) - Files.createDirectories(path) - val reqsDump = Json.encodeToString(requirementsBlockProcessor.detectedRequirements()) - Path.of(path.toFile().absolutePath, "sdpi-requirements.json").toFile().writeText(reqsDump) + writeArtifact("sdpi-requirements", jsonFormatter.encodeToString(infoCollector.info().requirements())) + writeArtifact("sdpi-use-cases", jsonFormatter.encodeToString(infoCollector.info().useCases())) + } + + asciidoctor.shutdown() } - private companion object { - const val BACKEND = "html" + private fun writeArtifact(strArtifactName: String, strArtifact: String) { + if (conversionOptions.extractsFolder != null) { + Files.createDirectories(conversionOptions.extractsFolder) + Path.of(conversionOptions.extractsFolder.absolutePathString(), "${strArtifactName}.json").toFile() + .writeText(strArtifact) + } } sealed interface Input { data class FileInput(val file: File) : Input data class StringInput(val string: String) : Input } - - sealed interface Mode { - object Productive : Mode - data class Test(val structureDump: OutputStream) : Mode - } } \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/ConvertAndVerifySupplement.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/ConvertAndVerifySupplement.kt index a78dc358..1a9a1845 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/ConvertAndVerifySupplement.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/ConvertAndVerifySupplement.kt @@ -1,19 +1,16 @@ package org.sdpi import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.required -import com.github.ajalt.clikt.parameters.options.validate +import com.github.ajalt.clikt.parameters.options.* import com.github.ajalt.clikt.parameters.types.choice import com.github.ajalt.clikt.parameters.types.file import org.apache.logging.log4j.kotlin.Logging import org.sdpi.asciidoc.AsciidocErrorChecker -import org.sdpi.asciidoc.github.IssueImport import java.io.File import kotlin.system.exitProcess -fun main(args: Array) = ConvertAndVerifySupplement().main(args +fun main(args: Array) = ConvertAndVerifySupplement().main( + args // when (System.getenv().containsKey("CI")) { // true -> args.firstOrNull()?.split(" ") ?: listOf() // caution: blanks in quotes not covered here! // false -> args.toList() @@ -47,6 +44,12 @@ class ConvertAndVerifySupplement : CliktCommand("convert-supplement") { private val githubToken by option("--github-token", help = "Github token to request issues") + private val dumpStructure by option("--dump-structure", help = "Writes document tree to std-out during processing") + .flag(default = false) + + private val testGenerator by option("--test", help = "Writes document without headers for test output") + .flag(default = false) + override fun run() { runCatching { val asciidocErrorChecker = AsciidocErrorChecker() @@ -59,7 +62,17 @@ class ConvertAndVerifySupplement : CliktCommand("convert-supplement") { logger.info { "Write output to '${outFile.canonicalPath}'" } - AsciidocConverter(AsciidocConverter.Input.FileInput(adocInputFile), outFile, githubToken).run() + AsciidocConverter( + AsciidocConverter.Input.FileInput(adocInputFile), + outFile.outputStream(), + ConverterOptions( + githubToken = githubToken, + extractsFolder = ConverterOptions.makeDefaultPath(outputFolder.absolutePath), + outputFormat = backend, + dumpStructure = dumpStructure, + generateTestOutput = testGenerator, + ) + ).run() asciidocErrorChecker.run() diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/BlockAttribute.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/BlockAttribute.kt index e9953f07..dc39cae3 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/BlockAttribute.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/BlockAttribute.kt @@ -7,7 +7,62 @@ enum class BlockAttribute(val key: String) { ID("id"), TITLE("title"), ROLE("role"), - REQUIREMENT_LEVEL("sdpi_req_level"), MAX_OCCURRENCE("sdpi_max_occurrence"), - VOLUME_CAPTION("sdpi_volume_caption") + VOLUME_CAPTION("sdpi_volume_caption"), + } + +/** + * Defines attribute keywords for sdpi_requirement blocks + */ +sealed class RequirementAttributes { + enum class Common(val key: String) { + // Type of requirement. + TYPE("sdpi_req_type"), + + // Requirement level (e.g., shall, may, etc) + LEVEL("sdpi_req_level"), + + // Identifies a specification that the requirement belongs + // to. Used, for example, to determine the base oid for + // assigning globally unique object ids. + SPECIFICATION("sdpi_req_specification"), + + // Groups requirement belongs to. Comma separated list. + GROUPS("sdpi_req_group"), + + } + + enum class UseCase(val key: String) { + // Attribute that identifies the use case associated with a + // USE_CASE requirement + ID("sdpi_use_case_id") + } + + enum class RefIcs(val key: String) { + // The id of the reference to the standard containing the requirement + ID("sdpi_ref_id"), + + // Section in the reference standard containing the requirement (e.g., "§6.2") + SECTION("sdpi_ref_section"), + + // Requirement identifier in the referenced standard (e.g., "R1001") + REQUIREMENT("sdpi_ref_req") + } + + enum class RiskMitigation(val key: String) { + // Type of risk (e.g., general, safety, effectiveness, etc) + SES_TYPE("sdpi_ses_type"), + + // How requirement can be tested (e.g., inspection, interoperability, etc.). + TESTABILITY("sdpi_ses_test") + } +} + +enum class UseCaseAttributes(val key: String) { + // Attribute to define an id for a use case section. + ID("sdpi_use_case_id"), + + // Attribute to define an id for a use case scenario. + SCENARIO("sdpi_use_case_scenario") +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/Util.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/Util.kt index 84393171..07670ee2 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/Util.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/Util.kt @@ -56,6 +56,16 @@ fun StructuralNode.isAppendix() = when (val section = this.toSealed()) { else -> false } +fun parseRequirementNumber(strRequirement: String): Int? { + val requirementParser = "^r(\\d+)$".toRegex() + val matchResults = requirementParser.findAll(strRequirement) + if (matchResults.any()) { + return matchResults.map { it.groupValues[1] }.toList().first().toInt() + } + + return null +} + /** * Takes a string and converts it to a [RequirementLevel] enum. * @@ -63,4 +73,23 @@ fun StructuralNode.isAppendix() = when (val section = this.toSealed()) { * * @return the [RequirementLevel] enum or null if the conversion failed (raw was not shall, should or may). */ -fun resolveRequirementLevel(raw: String) = RequirementLevel.values().firstOrNull { it.keyword == raw } \ No newline at end of file +fun resolveRequirementLevel(raw: String) = RequirementLevel.entries.firstOrNull { it.keyword == raw } + +/** + * Converts an object, typically from an attribute map, into + * a list of groups. + */ +fun getRequirementGroups(oGroups: Any?): List { + if (oGroups == null) { + return listOf() + } + return oGroups.toString().split(",") +} + +fun getLocation(block: StructuralNode): String { + return if (block.sourceLocation != null) { + "${block.sourceLocation.path}:${block.sourceLocation.lineNumber}" + } else { + ""; + } +} diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddICSPlaceholder.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddICSPlaceholder.kt new file mode 100644 index 00000000..913a4206 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddICSPlaceholder.kt @@ -0,0 +1,53 @@ +package org.sdpi.asciidoc.extension + +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.BlockMacroProcessor +import org.asciidoctor.extension.Name +import org.sdpi.asciidoc.BlockAttribute +import org.sdpi.asciidoc.RequirementAttributes + +const val BLOCK_MACRO_NAME_SDPI_ICS_TABLE = "sdpi_ics_table" + +/** + * The role applied to implementation conformance statement tables + * This role enables the ICS table populator to figure out which tables it + * needs to populate. + */ +const val ICS_TABLE_ROLE = "ics-table" + +/** + * Processor for ics table block macro. + * The ics table block macro provides a mechanism to insert a table + * summarizing requirements for conformance to the profile. For + * example, all requirement in a group. + * + * Filter parameters are included as macro attributes. Available filters are: + * - groups (sdpi_req_group): include requirements belonging to any + * of the supplied groups. + * + * Example: to include a table of all requirements in the consumer or + * discovery-proxy groups insert: + * sdpi_ics_table::[sdpi_req_group="consumer,discovery-proxy"] + * + * Not all requirements may be defined when the block macro is processed so + * this processor just inserts a placeholder for the ICS table to be populated + * later. + */ +@Name(BLOCK_MACRO_NAME_SDPI_ICS_TABLE) +class AddICSPlaceholder : BlockMacroProcessor(BLOCK_MACRO_NAME_SDPI_ICS_TABLE) { + + override fun process(parent: StructuralNode, target: String, attributes: MutableMap): StructuralNode { + attributes["role"] = ICS_TABLE_ROLE + val placeholderTable = createTable(parent) + placeholderTable.attributes["role"] = ICS_TABLE_ROLE + + // Add filter attributes to the table for the tree processor to consume. + val strGroup = attributes[RequirementAttributes.Common.GROUPS.key] + if (strGroup != null) { + placeholderTable.attributes[RequirementAttributes.Common.GROUPS.key] = strGroup + } + + return placeholderTable + } + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddRequirementQueryPlaceholder.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddRequirementQueryPlaceholder.kt new file mode 100644 index 00000000..f68fc2e2 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/AddRequirementQueryPlaceholder.kt @@ -0,0 +1,54 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.BlockMacroProcessor +import org.asciidoctor.extension.Name +import org.sdpi.asciidoc.RequirementAttributes + +const val BLOCK_MACRO_NAME_SDPI_REQUIREMENT_TABLE = "sdpi_requirement_table" + +/** + * The role applied to requirement list placeholder tables. This role + * enables the RequirementListProcessor to figure out which tables it + * needs to populate. + */ +const val REQUIREMENT_TABLE_ROLE = "requirement-table" + +/** + * Processor for requirement table block macro. + * The requirement table block macro provides a mechanism to insert a table + * summarizing requirements that match a filter included in the macro. For + * example, all requirement in a group. + * + * Filter parameters are included as macro attributes. Available filters are: + * - groups (sdpi_req_group): include requirements belonging to any + * of the supplied groups. + * + * Example: to include a table of all requirements in the consumer or + * discovery-proxy groups insert: + * sdpi_requirement_table::[sdpi_req_group="consumer,discovery-proxy"] + * + * Not all requirements may be defined when the block macro is processed so + * this processor prepares the document for the PopulateTables + * to actually populate the table; it simply inserts a table placeholder. + */ +@Name(BLOCK_MACRO_NAME_SDPI_REQUIREMENT_TABLE) +class AddRequirementQueryPlaceholder : BlockMacroProcessor(BLOCK_MACRO_NAME_SDPI_REQUIREMENT_TABLE) { + private companion object : Logging; + + override fun process(parent: StructuralNode, target: String, attributes: MutableMap): StructuralNode { + attributes["role"] = REQUIREMENT_TABLE_ROLE + val placeholderTable = createTable(parent) + placeholderTable.attributes["role"] = REQUIREMENT_TABLE_ROLE + + // Add filter attributes to the table for the tree processor to consume. + val strGroup = attributes[RequirementAttributes.Common.GROUPS.key] + if (strGroup != null) { + placeholderTable.attributes[RequirementAttributes.Common.GROUPS.key] = strGroup + } + + return placeholderTable + } + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/BibliographyCollector.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/BibliographyCollector.kt new file mode 100644 index 00000000..7bd6ceea --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/BibliographyCollector.kt @@ -0,0 +1,102 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.Document +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.Treeprocessor +import org.sdpi.asciidoc.getLocation +import org.sdpi.asciidoc.model.BibliographyEntry + +class BibliographyCollector : Treeprocessor() { + private companion object : Logging + + private val bibliographyEntries = mutableMapOf() + + override fun process(document: Document): Document { + collectBibliography(document) + return document + } + + fun findEntry(strRef: String): BibliographyEntry? { + return bibliographyEntries[strRef] + } + + private fun collectBibliography(document: Document): Boolean { + logger.info("Searching for bibliography") + val bib = findBibliography(document as StructuralNode) + if (bib == null) { + logger.warn("Can't find bibliography") + return false + } + + val reBibParser = Regex("\\[{3}(?\\w+)(,(?.+))?]{3}\\s+(?.+)") + + // Expecting first child to be a list of bibliography entries. + when (val bibList = bib.blocks[0]) { + + is org.asciidoctor.ast.List -> { + for (bibEntry in bibList.items) { + if (bibEntry is org.asciidoctor.ast.ListItem) { + val strItem = bibEntry.source + val mParsed = reBibParser.find(strItem) + checkNotNull(mParsed) + { + "${getLocation(bibEntry)} invalid format '$strItem'".also { logger.error { it } } + } + val strRef = mParsed.groups["ref"]?.value + checkNotNull(strRef) + { + "${getLocation(bibEntry)} missing reference".also { logger.error { it } } + } + val strRefText = mParsed.groups["reftxt"]?.value + checkNotNull(strRefText) + { + "${getLocation(bibEntry)} missing reference text for $strRef".also { logger.error { it } } + } + val strSource = mParsed.groups["entry"]?.value + checkNotNull(strSource) + { + "${getLocation(bibEntry)} missing reference source for $strRef".also { logger.error { it } } + } + + if (bibliographyEntries.contains(strRef)) { + "${getLocation(bibEntry)} duplicate reference id $strRef".also { logger.error { it } } + } + bibliographyEntries[strRef] = BibliographyEntry(strRef, strRefText, strSource) + } + } + } + + else -> { + "Can't find bibliography entries".also { logger.error { it } } + } + } + + logger.info("Found ${bibliographyEntries.count()} bibliography entries") + + return true + } + + private fun findBibliography(block: StructuralNode): StructuralNode? { + //logger.info("Searching ${block.sourceLocation.lineNumber}") + // The bibliography must be a section and have the bibliography style (see + // https://docs.asciidoctor.org/asciidoc/latest/sections/bibliography/#bibliography-section-syntax) + if (block.context == "section") { + val strStyle = block.attributes["style"]?.toString() + if (strStyle != null && strStyle == "bibliography") { + return block + } + } + + for (child in block.blocks) { + val bib = findBibliography(child) + if (bib != null) { + return bib + } + } + + return null + } + + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DisableSectNumsProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DisableSectNumsProcessor.kt index 6e54f200..7445ec68 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DisableSectNumsProcessor.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DisableSectNumsProcessor.kt @@ -3,13 +3,12 @@ package org.sdpi.asciidoc.extension import org.asciidoctor.ast.Document import org.asciidoctor.extension.Preprocessor import org.asciidoctor.extension.PreprocessorReader -import java.io.File /** * Removes any occurrences of `:sectnums:` from the document to prevent the AscidoctorJ parser from rendering * additional section numbers. */ -class DisableSectNumsProcessor() : Preprocessor() { +class DisableSectNumsProcessor : Preprocessor() { override fun process(document: Document, reader: PreprocessorReader) { reader.readLines().filter { it.trim() != ":sectnums:" diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DumpTreeInfo.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DumpTreeInfo.kt new file mode 100644 index 00000000..e8afdf48 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/DumpTreeInfo.kt @@ -0,0 +1,65 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.Document +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.Treeprocessor + +/** + * Writes information about the tree to stdio to aid understanding + * of document structure. + */ +class DumpTreeInfo : Treeprocessor() { + private companion object : Logging + + override fun process(document: Document): Document { + dumpBlock(document as StructuralNode, 0) + return document + } + + private fun dumpBlock(block: StructuralNode, nLevel: Int) { + val sbBlockInfo = StringBuilder() + val strIndent = " ".repeat(nLevel) + sbBlockInfo.append("${block.sourceLocation} > ") + sbBlockInfo.append(strIndent) + sbBlockInfo.append("${block.context}/${block.style}") + sbBlockInfo.append(" [${getRoles(block)}]") + sbBlockInfo.append(" '${shortTitle(block)}'") + sbBlockInfo.append(" #${block.id}") + //sbBlockInfo.append(" %${block.javaClass}%") + sbBlockInfo.append(" {${getAttributes(block)}}") + logger.debug { sbBlockInfo.toString() } + + block.blocks.forEach { dumpBlock(it, nLevel + 1) } + } + + private fun getRoles(block: StructuralNode): String { + var strRoles = "" + for (strRole in block.roles) { + strRoles += " '${strRole}'" + } + return strRoles + } + + private fun getAttributes(block: StructuralNode): String { + var strAttributes = "" + + for (strAttr in block.attributes.keys) { + strAttributes += " '${strAttr}' => '${block.attributes[strAttr]}'" + } + + return strAttributes + } + + private fun shortTitle(block: StructuralNode): String { + if (block.title == null) { + return "---" + } + val nMaxLength = 20 + val strTitle: String = block.title + if (strTitle.length <= nMaxLength) { + return strTitle + } + return "${strTitle.substring(0, nMaxLength - 1)}..." + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/IssuesSectionPreprocessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/IssuesSectionPreprocessor.kt index 5e4eb320..d5372577 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/IssuesSectionPreprocessor.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/IssuesSectionPreprocessor.kt @@ -5,8 +5,6 @@ import org.asciidoctor.extension.Preprocessor import org.asciidoctor.extension.PreprocessorReader import org.kohsuke.github.GHIssue import org.sdpi.asciidoc.github.IssueImport -import org.sdpi.asciidoc.github.Issues - class IssuesSectionPreprocessor( private val githubToken: String? @@ -14,7 +12,10 @@ class IssuesSectionPreprocessor( override fun process(document: org.asciidoctor.ast.Document, reader: PreprocessorReader) { val lines = reader.readLines() - if (githubToken == null) { + // Recognize a special value for the token. Forks can use this for actions to skip adding issues + // by adding the token SDPI_API_ACCESS_TOKEN_SECRET in (Fork)->Settings->Secrets and variables-> + // Actions->Repository secrets. + if (githubToken.isNullOrEmpty() || githubToken == "skip") { logger.info { "Skip requesting issues, no Github token available" } reader.restoreLines(lines) return diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/NumberingProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/NumberingProcessor.kt index 99b68255..998f3972 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/NumberingProcessor.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/NumberingProcessor.kt @@ -170,7 +170,9 @@ class NumberingProcessor( is StructuralNodeWrapper.Table -> replaceCaption(node.wrapped, "Table") - else -> logger.debug { "Ignore block of type '${block.context}'" } + else -> { + //logger.debug { "Ignore block of type '${block.context}'" } + } } } } diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/PopulateTables.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/PopulateTables.kt new file mode 100644 index 00000000..7130931d --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/PopulateTables.kt @@ -0,0 +1,133 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.Document +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.ast.Table +import org.asciidoctor.extension.Contexts +import org.asciidoctor.extension.Treeprocessor +import org.sdpi.asciidoc.BlockAttribute +import org.sdpi.asciidoc.RequirementAttributes +import org.sdpi.asciidoc.getRequirementGroups +import org.sdpi.asciidoc.model.SdpiRequirement2 +import org.sdpi.asciidoc.plainContext + +/** + * Tree processor to populate requirement table placeholders, which are inserted + * by Add*Placeholder block macros. + */ +class PopulateTables(private val docInfo: SdpiInformationCollector) : Treeprocessor() { + private companion object : Logging; + + override fun process(document: Document): Document { + processBlock(document as StructuralNode) + return document + } + + private fun processBlock(block: StructuralNode) { + if (block.hasRole(REQUIREMENT_TABLE_ROLE)) { + populateQueryTable(block as Table, getSelectedRequirements(block)) + } + if (block.hasRole(ICS_TABLE_ROLE)) { + populateICSTable(block as Table, getSelectedRequirements(block)) + } else { + for (child in block.blocks) { + processBlock(child) + } + } + } + + /** + * Determine which requirements should be included in the table. + */ + private fun getSelectedRequirements(block: Table): Collection { + val requirementsInDocument = docInfo.requirements() + + val aGroups = getRequirementGroups(block.attributes[RequirementAttributes.Common.GROUPS.key]) + if (aGroups.isEmpty()) { + // unfiltered + return requirementsInDocument.values + } + + val selectedRequirements = requirementsInDocument.values.filter { it -> it.groups.any { it in aGroups } } + return selectedRequirements + } + + /** + * Populates the table with the supplied requirements + */ + private fun populateQueryTable(table: Table, requirements: Collection) { + val colId = createTableColumn(table, 0) + val colLocalId = createTableColumn(table, 1) + val colLevel = createTableColumn(table, 2) + val colType = createTableColumn(table, 3) + + val header = createTableRow(table) + table.header.add(header) + + header.cells.add(createTableCell(colId, "Id")) + header.cells.add(createTableCell(colLocalId, "Local id")) + header.cells.add(createTableCell(colLevel, "Level")) + header.cells.add(createTableCell(colType, "Type")) + + for (req in requirements) { + val strGlobalId = req.globalId + val level = req.level + val strType = req.getTypeDescription() + + val strIdLink = req.makeLink() + + val cellGlobalId = createDocument(table.document) + cellGlobalId.blocks.add( + createBlock( + cellGlobalId, + plainContext(Contexts.PARAGRAPH), + strGlobalId, + mapOf("role" to "global-id") + ) + ) + + val row = createTableRow(table) + row.cells.add(createTableCell(colId, cellGlobalId)) + row.cells.add(createTableCell(colLocalId, strIdLink)) + row.cells.add(createTableCell(colLevel, level.keyword)) + row.cells.add(createTableCell(colType, strType)) + + table.body.add(row) + } + } + + + private fun populateICSTable(table: Table, requirements: Collection) { + val colId = createTableColumn(table, 0) + val colReference = createTableColumn(table, 1) + val colStatus = createTableColumn(table, 2) + val colSupport = createTableColumn(table, 3) + val colComment = createTableColumn(table, 4) + + val header = createTableRow(table) + table.header.add(header) + + header.cells.add(createTableCell(colId, "Index")) + header.cells.add(createTableCell(colReference, "Reference")) + header.cells.add(createTableCell(colStatus, "Status")) + header.cells.add(createTableCell(colSupport, "Support")) + header.cells.add(createTableCell(colComment, "Comment")) + + for (req in requirements) { + val strIcsId = String.format("ICS-%04d", req.requirementNumber) + val strReference = req.makeLinkGlobal() + val level = req.level + + val row = createTableRow(table) + row.cells.add(createTableCell(colId, strIcsId)) + row.cells.add(createTableCell(colReference, strReference)) + row.cells.add(createTableCell(colStatus, level.icsStatus)) + row.cells.add(createTableCell(colSupport, "")) + row.cells.add(createTableCell(colComment, "")) + + table.body.add(row) + } + } + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/ReferenceMacroProcessors.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/ReferenceMacroProcessors.kt new file mode 100644 index 00000000..f2bb8dc5 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/ReferenceMacroProcessors.kt @@ -0,0 +1,73 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.ContentNode +import org.asciidoctor.ast.PhraseNode +import org.asciidoctor.extension.InlineMacroProcessor +import org.asciidoctor.extension.Name +import org.sdpi.asciidoc.parseRequirementNumber + +/** + * Processes inline macro to reference a requirement. + * https://docs.asciidoctor.org/asciidoctorj/latest/extensions/inline-macro-processor/ + * Target: the local id of the requirement to reference. + * Attributes: none + * Output: a local link to the requirement specification. + * Example: + * RefRequirement:R1001[] + * + */ +@Name("RefRequirement") +class RequirementReferenceMacroProcessor(private val documentInfo: SdpiInformationCollector) : InlineMacroProcessor() { + private companion object : Logging + + override fun process(parent: ContentNode, strTarget: String, attributes: MutableMap): PhraseNode { + val id = parseRequirementNumber(strTarget) + checkNotNull(id) + { + "$strTarget is not a valid requirement number for a requirement reference".also { logger.error { it } } + } + + val req = documentInfo.requirements()[id] + checkNotNull(req) + { + "Requirement '$strTarget' ($id) doesn't exist".also { logger.error { it } } + } + + val strHref = "#${req.getBlockId()}" + val options: Map = mapOf("type" to ":link", "target" to strHref) + + return createPhraseNode(parent, "anchor", req.localId, attributes, options) + } +} + +/** + * Processes inline macro to reference a use-case. + * https://docs.asciidoctor.org/asciidoctorj/latest/extensions/inline-macro-processor/ + * Target: the case-sensitive id of the use-case to reference. + * Attributes: none + * Output: a local link to the use case specification. + * Example: + * RefUseCase:stad[] + * + */ +@Name("RefUseCase") +class UseCaseReferenceMacroProcessor(private val documentInfo: SdpiInformationCollector) : InlineMacroProcessor() { + private companion object : Logging + + override fun process(parent: ContentNode, strTarget: String, attributes: MutableMap): PhraseNode { + val useCase = documentInfo.useCases()[strTarget] + checkNotNull(useCase) + { + "Use case '$strTarget' doesn't exist".also { logger.error { it } } + } + + val strHref = "#${useCase.anchor}" + val options: Map = mapOf("type" to ":link", "target" to strHref) + + logger.info("Found use case: $strHref, ${useCase.title}") + + return createPhraseNode(parent, "anchor", useCase.title, attributes, options) + } + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RelatedBlockProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RelatedBlockProcessor.kt new file mode 100644 index 00000000..a0448d9b --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RelatedBlockProcessor.kt @@ -0,0 +1,45 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.Options +import org.asciidoctor.ast.ContentModel +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.BlockProcessor +import org.asciidoctor.extension.Contexts +import org.asciidoctor.extension.Name +import org.asciidoctor.extension.Reader + + +const val BLOCK_NAME_SDPI_REQUIREMENT_RELATED = "RELATED" + +/** + * Formats related blocks inside requirement section. + * Related blocks lists standards that are related to the containing requirement, + * for example a requirement from another standard that informs this requirement + * or may provide additional information that may be useful to meet the requirement. + */ +@Name(BLOCK_NAME_SDPI_REQUIREMENT_RELATED) +@Contexts(Contexts.EXAMPLE) +class RelatedBlockProcessor : BlockProcessor(BLOCK_NAME_SDPI_REQUIREMENT_RELATED) { + private companion object : Logging; + + override fun process(parent: StructuralNode, reader: Reader, attributes: MutableMap): Any { + //logger.info("**** Found related block with parent: ${parent.attributes["title"]}") + + // Make this block collapsible and collapsed by default. + attributes["collapsible-option"] = "" + + attributes["name"] = BLOCK_NAME_SDPI_REQUIREMENT_RELATED + attributes["style"] = "RELATED" + val block = createBlock( + parent, "example", + mapOf( + Options.ATTRIBUTES to attributes, + ContentModel.KEY to ContentModel.COMPOUND + ) + ) + block.title = "Related" + + return block + } +} diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementBlockProcessor2.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementBlockProcessor2.kt new file mode 100644 index 00000000..4b663403 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementBlockProcessor2.kt @@ -0,0 +1,191 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.Options +import org.asciidoctor.ast.ContentModel +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.BlockProcessor +import org.asciidoctor.extension.Contexts +import org.asciidoctor.extension.Name +import org.asciidoctor.extension.Reader +import org.sdpi.asciidoc.* +import java.util.* + +const val BLOCK_NAME_SDPI_REQUIREMENT = "sdpi_requirement" + +/** + * Block processor for sdpi_requirement blocks. + * Requirement blocks provide metadata for profile requirements. Metadata is + * provided through block attributes and includes: + * - level (sdpi_req_level): may|should|shall + * - type (sdpi_req_type): one of + * - tech_feature: a basic technical requirement that isn't one of the other categories + * - use_case_feature: a requirement for a profile-independent use case specification + * - ihe_profile: requirement associated to an aspect of an IHE profile specification + * - ref_ics: referenced implementation conformance statement from another standard + * - risk_mitigation: requirement typically related to risk management + * - groups (sdpi_req_group): comma separated list of groups that the requirement belongs to; + * may be used as a filter by the RequirementListMacroProcessor + * - sdpi_req_specification: id of specification that owns the requirement; specifications are + * defined in document level attribute entries of the form "spid_oid." and + * map to an oid for the specification. + * + * The requirement number is determine from the block id or title. + * + * - Checks for requirement number duplicates + * - Stores all requirements in [RequirementsBlockProcessor.detectedRequirements] for further processing + * + * Example: + * // Define object ids for referenced standards + * :sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + * :sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 + * :sdpi_oid.sdpi-a: 1.3.6.1.4.1.19376.1.6.2.x + * + * // An example requirement block + * .R1021 + * [sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="provider,discovery-proxy",sdpi_req_specification=sdpi-p] + * **** + * + * [NORMATIVE] + * When the vol1_spec_sdpi_p_option_discovery_proxy is enabled for a vol1_spec_sdpi_p_actor_somds_provider Actor, then it shall use the vol2_clause_dev_46, DEV-46 transaction to update the vol1_spec_sdpi_p_actor_discovery_proxy Actor on its network presence and departure. + * + * [NOTE] + * ==== + * . doh + * . ray + * . me + * ==== + * + * .Related + * [%collapsible] + * ==== + * . <>, section 3.1 + * . <>, §C.57, R0121 + * ==== + * + * **** + */ +@Name(BLOCK_NAME_SDPI_REQUIREMENT) +@Contexts(Contexts.SIDEBAR) +@ContentModel(ContentModel.COMPOUND) +class RequirementBlockProcessor2 : BlockProcessor(BLOCK_NAME_SDPI_REQUIREMENT) { + private companion object : Logging { + val REQUIREMENT_TITLE_FORMAT = "^([A-Z])*?R(\\d+)$".toRegex() + const val REQUIREMENT_ROLE = "requirement" + } + + override fun process(parent: StructuralNode, reader: Reader, attributes: MutableMap): Any { + val requirementNumber: Int = getRequirementNumber(attributes) + val strGlobalId = getRequirementOid(parent, requirementNumber, attributes) + val strLinkId = String.format("r%04d", requirementNumber) + + logger.info("Found requirement #$requirementNumber at ${parent.sourceLocation}") + + attributes["id"] = strLinkId + if (strGlobalId != null) { + attributes["global-id"] = strGlobalId + } + attributes["reftext"] = String.format("R%04d", requirementNumber) + attributes["requirement-number"] = requirementNumber + if (strGlobalId != null) { + attributes["title"] = formatRequirementTitle(requirementNumber, strGlobalId) + } + attributes["role"] = REQUIREMENT_ROLE + + // Include an empty block with an id in the global format. + if (strGlobalId != null) { + val anchorBlock = createBlock(parent, plainContext(Contexts.OPEN), Collections.emptyList(), mapOf()) + anchorBlock.id = strGlobalId + parent.append(anchorBlock) + } + + return createBlock( + parent, plainContext(Contexts.SIDEBAR), mapOf( + Options.ATTRIBUTES to attributes, // copy attributes for further processing + ContentModel.KEY to ContentModel.COMPOUND, // signify construction of a compound object + ) + ) + } + + + /** + * Retrieve the requirement number from available attributes. + * The requirement number may be specified as a block id or title. + * Requirement numbers must match the format defined by REQUIREMENT_NUMBER_FORMAT + * or REQUIREMENT_TITLE_FORMAT for the id or title, respectively. + */ + private fun getRequirementNumber(mutableAttributes: MutableMap): Int { + val strId = mutableAttributes["id"] + if (strId != null) { + val id = parseRequirementNumber(strId.toString()) + checkNotNull(id) + { + "id '$strId' is not a valid requirement number".also { logger.error { it } } + } + return id + } + + val strTitle = mutableAttributes["title"] + return REQUIREMENT_TITLE_FORMAT.findAll(strTitle.toString()) + .map { it.groupValues[2] }.toList().first().toInt() + } + + /** + * Retrieve the globally unique object id for the requirement. + * See Assigning Unique Identifiers [[SDPi:§1:A.4.2.1]] + * + */ + private fun getRequirementOid( + parent: StructuralNode, + requirementNumber: Int, + mutableAttributes: MutableMap + ): String? { + val strSourceSpecification = mutableAttributes[RequirementAttributes.Common.SPECIFICATION.key] + + // To simplify transition, we'll print a warning and allow the oid to be missing. . + if (strSourceSpecification == null) { + logger.warn("${getLocation((parent))} missing source specification for requirement #${requirementNumber}. In the future this will be an error.") + return null + } + + checkNotNull(strSourceSpecification) { + ("Missing requirement source id for SDPi requirement #$requirementNumber [${getLocation(parent)}]").also { + logger.error(it) + } + } + val strSourceId = getOidFor(parent, requirementNumber, strSourceSpecification as String) + + // Global unique ids are composed of the source specification's oid, + // ".2." + the requirement number. [[SDPi:§1:A.4.2.1]] + return "$strSourceId.2.$requirementNumber" + } + + /** + * Retrieves the oid definition corresponding to an oid id. + * Oids are defined as document level attributes in the spid_oid configuration scope. For + * example: + * :sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + */ + private fun getOidFor(parent: StructuralNode, requirementNumber: Int, strOidId: String): String { + val document = parent.document + val strAttribute = "sdpi_oid${strOidId}" + val strOid = document.attributes[strAttribute] + checkNotNull(strOid) { + ("The oid id ('${strOidId}') for SDPi requirement #'${requirementNumber}' cannot be found [${ + getLocation( + parent + ) + }].").also { + logger.error(it) + } + } + return strOid as String + } + + /** + * Creates text for the requirement title in the document + */ + private fun formatRequirementTitle(requirementNumber: Int, strGlobalId: String): String { + return "R${String.format("%04d", requirementNumber)} [.global-id]#`(${strGlobalId})`#" + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementExampleBlockProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementExampleBlockProcessor.kt new file mode 100644 index 00000000..cd76dfc3 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementExampleBlockProcessor.kt @@ -0,0 +1,44 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.Options +import org.asciidoctor.ast.ContentModel +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.BlockProcessor +import org.asciidoctor.extension.Contexts +import org.asciidoctor.extension.Name +import org.asciidoctor.extension.Reader + +const val BLOCK_NAME_SDPI_REQUIREMENT_EXAMPLE = "EXAMPLE" + +/** + * Formats example blocks inside a requirement section. + * Example blocks provide examples that may be useful to meet the requirement. + */ +@Name(BLOCK_NAME_SDPI_REQUIREMENT_EXAMPLE) +@Contexts(Contexts.EXAMPLE) +class RequirementExampleBlockProcessor : BlockProcessor(BLOCK_NAME_SDPI_REQUIREMENT_EXAMPLE) { + private companion object : Logging; + + override fun process(parent: StructuralNode, reader: Reader, attributes: MutableMap): Any { + //logger.info("**** Found example block with parent: ${parent.attributes["title"]}") + + // Make this block collapsible and collapsed by default. + attributes["collapsible-option"] = "" + + attributes["name"] = BLOCK_NAME_SDPI_REQUIREMENT_EXAMPLE + attributes["style"] = "EXAMPLE" + + + val block = createBlock( + parent, "example", + mapOf( + Options.ATTRIBUTES to attributes, + ContentModel.KEY to ContentModel.COMPOUND + ) + ) + block.title = "Example" + + return block + } +} diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementLevelProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementLevelProcessor.kt deleted file mode 100644 index 7c946b10..00000000 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementLevelProcessor.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.sdpi.asciidoc.extension - -import org.apache.logging.log4j.kotlin.Logging -import org.asciidoctor.ast.Block -import org.asciidoctor.ast.Document -import org.asciidoctor.ast.StructuralNode -import org.asciidoctor.extension.Treeprocessor -import org.sdpi.asciidoc.* -import org.sdpi.asciidoc.model.RequirementLevel -import org.sdpi.asciidoc.model.StructuralNodeWrapper -import org.sdpi.asciidoc.model.toSealed - -/** - * Checks expected requirement levels in SDPi requirements. - * - * Loops all paragraphs of sdpi_requirement blocks and checks if there is exactly one requirement level keyword that - * equals the value of the attribute key sdpi_req_level. - * - * Exits with an error if - * - * - there are multiple different keywords - * - if no keyword is found - * - if more than one occurrence of the keyword is found - */ -class RequirementLevelProcessor : Treeprocessor() { - override fun process(document: Document): Document { - processBlock(document as StructuralNode) - return document - } - - private fun processBlock(block: StructuralNode) { - block.toSealed().let { node -> - when (node) { - is StructuralNodeWrapper.SdpiRequirement -> { - processRequirement(node.wrapped) - } - else -> logger.debug { "Ignore block of type '${block.context}'" } - } - } - - block.blocks.forEach { processBlock(it) } - } - - private fun processRequirement(block: Block) { - val attributes = Attributes.create(block.attributes) - val levelRaw = attributes[BlockAttribute.REQUIREMENT_LEVEL] - checkNotNull(levelRaw) { - ("Missing requirement level for SDPi requirement with id ${attributes[BlockAttribute.ID]}").also { - logger.error { it } - } - } - val level = resolveRequirementLevel(levelRaw) - checkNotNull(level) { - ("Invalid requirement level for SDPi requirement with id ${attributes[BlockAttribute.ID]}: $level").also { - logger.error { it } - } - } - - val msgPrefix = "Check requirement level for #${attributes[BlockAttribute.ID]} (${level.keyword}):" - var levelCount = 0 - block.blocks.forEach { reqBlock -> - reqBlock.toSealed().let { childNode -> - when (childNode) { - is StructuralNodeWrapper.Paragraph -> { - levelCount += childNode.wrapped.source - .split(" ") - .map { it.trim() } - .count { it == level.keyword } - - RequirementLevel.values().filter { it != level }.forEach { notLevel -> - val notLevelCount = childNode.wrapped.source - .split(" ") - .map { it.trim() } - .count { it == notLevel.keyword } - check(notLevelCount == 0) { - ("$msgPrefix Requirement level keyword '${notLevel.keyword}' found").also { - logger.error { it } - } - } - } - } - else -> Unit - } - } - } - - check(levelCount > 0) { - ("$msgPrefix Requirement level keyword is missing").also { - logger.error { it } - } - } - - check(levelCount == 1) { - ("$msgPrefix Only one requirement level keyword allowed per requirement").also { - logger.error { it } - } - } - - logger.info { "$msgPrefix Done" } - } - - private companion object : Logging -} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementsBlockProcessor.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementsBlockProcessor.kt deleted file mode 100644 index b059ee7c..00000000 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/RequirementsBlockProcessor.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.sdpi.asciidoc.extension - -import org.apache.logging.log4j.kotlin.Logging -import org.asciidoctor.Options -import org.asciidoctor.ast.ContentModel -import org.asciidoctor.ast.StructuralNode -import org.asciidoctor.extension.BlockProcessor -import org.asciidoctor.extension.Contexts -import org.asciidoctor.extension.Name -import org.asciidoctor.extension.Reader -import org.sdpi.asciidoc.* -import org.sdpi.asciidoc.model.SdpiRequirement - -const val BLOCK_NAME_SDPI_REQUIREMENT = "sdpi_requirement" - -private typealias RequirementNumber = Int -private typealias Occurrence = Int - -/** - * Block processor that searches for sdpi_req blocks. - * - * - Checks for requirement number duplicates - * - Stores all requirements in [RequirementsBlockProcessor.detectedRequirements] for further processing - */ -@Name(BLOCK_NAME_SDPI_REQUIREMENT) -@Contexts(Contexts.SIDEBAR) -@ContentModel(ContentModel.COMPOUND) -class RequirementsBlockProcessor : BlockProcessor(BLOCK_NAME_SDPI_REQUIREMENT) { - private val intendedDuplicates = mutableMapOf() - - private companion object : Logging { - val REQUIREMENT_NUMBER_FORMAT = "^r(\\d+)$".toRegex() - val REQUIREMENT_TITLE_FORMAT = "^([A-Z])*?R(\\d+)$".toRegex() - const val REQUIREMENT_ROLE = "requirement" - } - - private val detectedRequirements = mutableMapOf() - - /** - * Returns all requirements that were collected by this block processor. - * - * Make sure to only call this function once the conversion ended as otherwise this map will be empty. - */ - fun detectedRequirements(): Map = detectedRequirements - - override fun process( - parent: StructuralNode, reader: Reader, - attributes: MutableMap - ): Any = retrieveRequirement( - reader, - Attributes.create(attributes) - ).let { requirement -> - logger.info { "Found SDPi requirement #${requirement.number}: $requirement" } - requirement.asciiDocAttributes[BlockAttribute.ROLE] = REQUIREMENT_ROLE - storeRequirement(requirement) - createBlock( - parent, plainContext(Contexts.SIDEBAR), mapOf( - Options.ATTRIBUTES to attributes, // copy attributes for further processing - ContentModel.KEY to ContentModel.COMPOUND // signify construction of a compound object - ) - ).also { - // make sure to separately parse contents since reader was requested by retrieveRequirement() - // and is EOF now - parseContent(it, requirement.asciiDocLines) - } - } - - private fun retrieveRequirement(reader: Reader, attributes: Attributes): SdpiRequirement { - val matchResults = REQUIREMENT_NUMBER_FORMAT.findAll(attributes.id()) - val requirementNumber = matchResults.map { it.groupValues[1] }.toList().first().toInt() - val maxOccurrence = attributes[BlockAttribute.MAX_OCCURRENCE]?.toInt() ?: 1 - - if (intendedDuplicates.containsKey(requirementNumber)) { - intendedDuplicates[requirementNumber] = intendedDuplicates[requirementNumber]!! + 1 - } else { - intendedDuplicates[requirementNumber] = 1 - } - - val lines = reader.readLines() - val requirementLevel = - checkNotNull(resolveRequirementLevel(attributes[BlockAttribute.REQUIREMENT_LEVEL] ?: "")) { - ("Missing requirement level for SDPi requirement #$requirementNumber").also { - logger.error { it } - } - } - try { - return SdpiRequirement( - requirementNumber, - requirementLevel, - maxOccurrence, - attributes, - lines - ) - } catch (e: Exception) { - logger.error { "Error while processing requirement #$requirementNumber: ${e.message}" } - throw e - } - } - - private fun storeRequirement(requirement: SdpiRequirement) { - validateRequirement(requirement) - detectedRequirements[requirement.number] = requirement - } - - private fun validateRequirement(requirement: SdpiRequirement) { - val reqNumberFromTitle = REQUIREMENT_TITLE_FORMAT.findAll(requirement.blockTitle) - .map { it.groupValues[2] }.toList().first().toInt() - check(reqNumberFromTitle == requirement.number) { - ("SDPi requirement title format is wrong or number differs from ID: " + - "title=${requirement.blockTitle}, id=${requirement.blockId}").also { - logger.error { it } - } - } - - checkNotNull(requirement.asciiDocAttributes[BlockAttribute.ROLE]) { - "SDPi requirement #'${requirement.number}' has no role; expected '$REQUIREMENT_ROLE'".also { - logger.error { it } - } - } - - check( - !detectedRequirements.containsKey(requirement.number) || - intendedDuplicates[requirement.number]!! <= requirement.maxOccurrence - ) { - "SDPi requirement #'${requirement.number}' already exists".also { - logger.error { it } - } - } - } -} - diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/Roles.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/Roles.kt new file mode 100644 index 00000000..c182ec0e --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/Roles.kt @@ -0,0 +1,22 @@ +package org.sdpi.asciidoc.extension + +/** + * Defines keywords for block roles. + */ +sealed class RoleNames { + enum class UseCase(val key: String) { + // Use case section + FEATURE("use-case"), + + // The block providing technical pre-conditions for the use case. + // Must be inside a feature section. + BACKGROUND("use-case-background"), + + // Block describing one scenario in the use case. Must be + // inside a feature section. + SCENARIO("use-case-scenario"), + + // Steps for the scenario. Must be inside a scenario section. + STEPS("use-case-steps"), + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/SdpiInformationCollector.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/SdpiInformationCollector.kt new file mode 100644 index 00000000..d2cc9cdc --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/extension/SdpiInformationCollector.kt @@ -0,0 +1,599 @@ +package org.sdpi.asciidoc.extension + +import org.apache.logging.log4j.kotlin.Logging +import org.asciidoctor.ast.ContentNode +import org.asciidoctor.ast.Document +import org.asciidoctor.ast.StructuralNode +import org.asciidoctor.extension.DocinfoProcessor +import org.asciidoctor.extension.Postprocessor +import org.asciidoctor.extension.Treeprocessor +import org.sdpi.asciidoc.* +import org.sdpi.asciidoc.model.* + +class TreeInfoCollector(bibliography: BibliographyCollector) : Treeprocessor() { + private val collector = SdpiInformationCollector(bibliography) + + fun info(): SdpiInformationCollector = collector + + override fun process(document: Document): Document { + collector.process(document) + + return document + } + +} + +class DocInfoCollector(bibliography: BibliographyCollector) : DocinfoProcessor() { + private val collector = SdpiInformationCollector(bibliography) + + fun info(): SdpiInformationCollector = collector + + override fun process(document: Document): String { + collector.process(document) + + return "" + } +} + +class DocInfoPostCollector(bibliography: BibliographyCollector) : Postprocessor() { + private val collector = SdpiInformationCollector(bibliography) + + fun info(): SdpiInformationCollector = collector + + override fun process(document: Document, strOutput: String): String { + collector.process(document) + + return "" + } +} + +class SdpiInformationCollector(private val bibliography: BibliographyCollector) { + private companion object : Logging + + private val requirements = mutableMapOf() + + private val useCases = mutableMapOf() + + fun requirements(): Map = requirements + + fun useCases(): Map = useCases + + fun process(document: Document) { + logger.info("Collecting sdpi information") + processBlock(document as StructuralNode) + } + + + private fun processBlock(block: StructuralNode) { + if (block.hasRole("requirement")) { + processRequirement(block) + } + + if (block.hasRole(RoleNames.UseCase.FEATURE.key)) { + processUseCase(block) + } + + for (child in block.blocks) { + processBlock(child) + } + } + + + //region Requirements + private fun processRequirement(block: StructuralNode) { + val nRequirementNumber = block.attributes["requirement-number"].toString().toInt() + check(!requirements.contains(nRequirementNumber)) // check for duplicate. + { + val strRequirement = block.attributes["requirement-number"].toString() + "Duplicate requirement #${strRequirement}: ${block.sourceLocation.path}:${block.sourceLocation.lineNumber}".also { + logger.error { it } + } + } + + val strLocalId = String.format("R%04d", nRequirementNumber) + val strGlobalId = block.attributes["global-id"]?.toString() ?: "" + val aGroups: List = getRequirementGroupMembership(block) + val requirementLevel: RequirementLevel = getRequirementLevel(nRequirementNumber, block) + val requirementType: RequirementType = getRequirementType(nRequirementNumber, block) + + logger.info("Requirement: $nRequirementNumber") + + + val specification = getSpecification(block, nRequirementNumber) + checkSpecificationLevel(nRequirementNumber, requirementLevel, specification, block) + + when (requirementType) { + RequirementType.TECH -> + requirements[nRequirementNumber] = SdpiRequirement2.TechFeature( + nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + + RequirementType.USE_CASE -> + requirements[nRequirementNumber] = buildUseCaseRequirement( + block, nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + + RequirementType.REF_ICS -> + requirements[nRequirementNumber] = buildRefIcsRequirement( + block, nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + + RequirementType.RISK_MITIGATION -> + requirements[nRequirementNumber] = buildRiskMitigationRequirement( + block, nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + + RequirementType.IHE_PROFILE -> buildIheProfileRequirement( + block, nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + } + } + + private fun getSpecification(block: StructuralNode, nRequirementNumber: Int): RequirementSpecification { + val normativeContent: MutableList = mutableListOf() + val noteContent: MutableList = mutableListOf() + val exampleContent: MutableList = mutableListOf() + val relatedContent: MutableList = mutableListOf() + val unstyledContent: MutableList = mutableListOf() + + for (child in block.blocks) { + val strStyle = child.attributes["style"].toString() + when (strStyle) { + "NORMATIVE" -> { + normativeContent.addAll(getContent_Obj(child)) + } + + "NOTE" -> { + noteContent.addAll(getContent_Obj(child)) + } + + "RELATED" -> { + relatedContent.addAll(getContent_Obj(child)) + } + + "EXAMPLE" -> { + exampleContent.addAll(getContent_Obj(child)) + } + + "example" -> { + logger.warn("Notes should be an example block in requirement #${nRequirementNumber}. In the future this will be an error") + noteContent.addAll(getContent_Obj(child)) + } + + else -> { + logger.warn("Unstyled content in requirement #${nRequirementNumber}. In the future this will be an error") + + unstyledContent.addAll(getContent_Obj(child)) + } + } + } + + // treat plain paragraphs as the normative content for + // backwards compatibility. + if (normativeContent.isEmpty()) { + logger.warn("${block.sourceLocation} is missing normative content section; using unstyled paragraphs. This will be an error in the future") + normativeContent.addAll(unstyledContent) + } + + return RequirementSpecification(normativeContent, noteContent, exampleContent, relatedContent) + } + + private fun checkSpecificationLevel( + nRequirementNumber: Int, + expectedLevel: RequirementLevel, + specification: RequirementSpecification, + block: StructuralNode + ) { + val normativeStatement = specification.normativeContent + val nMayCount = countKeyword(RequirementLevel.MAY.keyword, normativeStatement) + val nShallCount = countKeyword(RequirementLevel.SHALL.keyword, normativeStatement) + val nShouldCount = countKeyword(RequirementLevel.SHOULD.keyword, normativeStatement) + + logger.info("Check requirement level for #$nRequirementNumber ($expectedLevel)") + + check(normativeStatement.isNotEmpty()) + { + "${block.sourceLocation} requirement #$nRequirementNumber is missing the required normative statement".also { logger.error { it } } + } + + when (expectedLevel) { + RequirementLevel.MAY -> { + check(nMayCount == 1) + { + "${getLocation(block)} requirement #$nRequirementNumber should have exactly one lower-case may keyword, not $nMayCount".also { logger.error { it } } + } + + check(nShallCount == 0 && nShouldCount == 0) + { + "${getLocation(block)} requirement #$nRequirementNumber should not have any shall ($nShallCount) or should ($nShouldCount)".also { logger.error { it } } + } + } + + RequirementLevel.SHOULD -> { + check(nShouldCount == 1) + { + "${getLocation(block)} requirement #$nRequirementNumber should have exactly one lower-case should keyword, not $nShouldCount".also { logger.error { it } } + } + + check(nShallCount == 0 && nMayCount == 0) + { + "${getLocation(block)} requirement #$nRequirementNumber should not have any shall ($nShallCount) or may ($nMayCount)".also { logger.error { it } } + } + } + + RequirementLevel.SHALL -> { + check(nShallCount == 1) + { + "${getLocation(block)} requirement #$nRequirementNumber should have exactly one lower-case shall keyword, not $nShallCount".also { logger.error { it } } + } + + check(nShouldCount == 0 && nMayCount == 0) + { + "${getLocation(block)} requirement #$nRequirementNumber should not have any should ($nShouldCount) or may ($nMayCount)".also { logger.error { it } } + } + } + } + + } + + private fun buildUseCaseRequirement( + block: StructuralNode, nRequirementNumber: Int, + strLocalId: String, strGlobalId: String, + requirementLevel: RequirementLevel, aGroups: List, + specification: RequirementSpecification + ): SdpiRequirement2 { + val useCaseHeader: ContentNode = getUseCaseNode(nRequirementNumber, block.parent) + val useCaseId = useCaseHeader.attributes[RequirementAttributes.UseCase.ID.key] + checkNotNull(useCaseId) { + "Can't find use case id for requirement #${nRequirementNumber}".also { + logger.error { it } + } + } + + block.attributes[RequirementAttributes.UseCase.ID.key] = useCaseId.toString() + return SdpiRequirement2.UseCase( + nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification, useCaseId.toString() + ) + } + + private fun buildRefIcsRequirement( + block: StructuralNode, nRequirementNumber: Int, + strLocalId: String, strGlobalId: String, + requirementLevel: RequirementLevel, aGroups: List, + specification: RequirementSpecification + ): SdpiRequirement2 { + val strStandardId = block.attributes[RequirementAttributes.RefIcs.ID.key]?.toString() + checkNotNull(strStandardId) { + "Missing standard id for requirement #${nRequirementNumber}".also { logger.error(it) } + } + + val section = block.attributes[RequirementAttributes.RefIcs.SECTION.key] + val requirement = block.attributes[RequirementAttributes.RefIcs.REQUIREMENT.key] + check(section != null || requirement != null) { + "At least one of ${RequirementAttributes.RefIcs.SECTION.key} or ${RequirementAttributes.RefIcs.REQUIREMENT.key} is required for requirement #${nRequirementNumber}".also { + logger.error( + it + ) + } + } + + val bibEntry = bibliography.findEntry(strStandardId) + checkNotNull(bibEntry) + { + "${getLocation(block)} bibliography entry for $strStandardId is missing".also { logger.error { it } } + } + val strRefSource = bibEntry.source + + val strSection = section?.toString() ?: "" + val strRequirement = requirement?.toString() ?: "" + return SdpiRequirement2.ReferencedImplementationConformanceStatement( + nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification, + strStandardId, strRefSource, strSection, strRequirement + ) + + } + + private fun buildRiskMitigationRequirement( + block: StructuralNode, nRequirementNumber: Int, + strLocalId: String, strGlobalId: String, + requirementLevel: RequirementLevel, aGroups: List, + specification: RequirementSpecification + ): SdpiRequirement2 { + val sesType = block.attributes[RequirementAttributes.RiskMitigation.SES_TYPE.key] + checkNotNull(sesType) { + "Missing ses type for requirement #${nRequirementNumber}".also { logger.error(it) } + } + + val strSesType = sesType.toString() + val parsedSesType = RiskMitigationType.entries.firstOrNull { it.keyword == strSesType } + checkNotNull(parsedSesType) { + "Invalid ses type ($strSesType) for requirement #${nRequirementNumber}".also { logger.error(it) } + } + + val testability = block.attributes[RequirementAttributes.RiskMitigation.TESTABILITY.key] + checkNotNull(testability) { + "Missing test type for requirement #${nRequirementNumber}".also { logger.error(it) } + } + + val strTest = testability.toString() + val parsedTestability = RiskMitigationTestability.entries.firstOrNull { it.keyword == strTest } + checkNotNull(parsedTestability) { + "Invalid test type ($strTest) for requirement #${nRequirementNumber}".also { logger.error(it) } + } + + return SdpiRequirement2.RiskMitigation( + nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification, + parsedSesType, parsedTestability + ) + + } + + private fun buildIheProfileRequirement( + block: StructuralNode, nRequirementNumber: Int, + strLocalId: String, strGlobalId: String, + requirementLevel: RequirementLevel, aGroups: List, + specification: RequirementSpecification + ): SdpiRequirement2 { + check(false) { + "Currently unsupported".also { logger.error(it) } + } + + return SdpiRequirement2.TechFeature( + nRequirementNumber, + strLocalId, strGlobalId, requirementLevel, aGroups, specification + ) + } + + private fun getContent_Obj(block: StructuralNode): Collection { + val contents: MutableList = mutableListOf() + + val strContext = block.context + when (strContext) { + "paragraph" -> { + getBlockContent_Obj(contents, block, 0) + } + + "admonition", "example" -> { + for (child in block.blocks) { + getBlockContent_Obj(contents, child, 0) + } + } + + else -> { + logger.error("Unknown content type ${block.javaClass}") + } + } + + return contents + } + + private fun getBlockContent_Obj(contents: MutableList, block: StructuralNode, nLevel: Int) { + when (block) { + is org.asciidoctor.ast.List -> { + val items: MutableList = mutableListOf() + for (item: StructuralNode in block.items) { + getBlockContent_Obj(items, item, nLevel) + } + if (block.context == "olist") { + contents.add(Content.OrderedList(items.toList())) + } else { + contents.add(Content.UnorderedList(items.toList())) + } + } + + is org.asciidoctor.ast.ListItem -> { + contents.add(Content.ListItem(block.marker, block.source)) + } + + is org.asciidoctor.ast.Block -> { + val lines: MutableList = mutableListOf() + for (strLine in block.lines) { + lines.add(strLine) + } + + if (block.context == "listing") { + val strTitle: String = block.title ?: "" + contents.add(Content.Listing(strTitle, lines)) + } else { + contents.add(Content.Block(lines)) + } + } + + else -> { + logger.info("${getLocation(block)} unknown: ${block.javaClass}") + } + } + } + + /** + * Retrieves the list of groups the requirement belongs to (if any). + */ + private fun getRequirementGroupMembership(block: StructuralNode): List { + return getRequirementGroups(block.attributes[RequirementAttributes.Common.GROUPS.key]) + } + + /** + * Retrieve the requirement level + */ + private fun getRequirementLevel(requirementNumber: Int, block: StructuralNode): RequirementLevel { + val strLevel = block.attributes[RequirementAttributes.Common.LEVEL.key] + checkNotNull(strLevel) { + ("Missing ${RequirementAttributes.Common.LEVEL.key} attribute for SDPi requirement #$requirementNumber").also { + logger.error { it } + } + } + val reqLevel = resolveRequirementLevel(strLevel.toString()) + checkNotNull(reqLevel) { + ("Invalid requirement level '${strLevel}' for SDPi requirement #$requirementNumber").also { + logger.error { it } + } + } + + return reqLevel + } + + /** + * Retrieve the requirement type + */ + private fun getRequirementType(requirementNumber: Int, block: StructuralNode): RequirementType { + var strType = block.attributes[RequirementAttributes.Common.TYPE.key] + + // For now, assume tech feature by default for backwards compatibility. + if (strType == null) { + strType = "tech_feature" + logger.warn("${getLocation(block)}, requirement type missing for #$requirementNumber, assuming $strType. In the future this will be an error.") + } + + checkNotNull(strType) { + ("Missing ${RequirementAttributes.Common.TYPE.key} attribute for SDPi requirement #$requirementNumber [${ + getLocation( + block + ) + }]").also { + logger.error { it } + } + } + val reqType = RequirementType.entries.firstOrNull { it.keyword == strType } + checkNotNull(reqType) { + ("Invalid requirement type '${strType}' for SDPi requirement #$requirementNumber [${getLocation(block)}]").also { + logger.error { it } + } + } + + return reqType + } + + private fun getUseCaseNode(requirementNumber: Int, parent: ContentNode): ContentNode { + var node: ContentNode? = parent + while (node != null) { + val attr = node.attributes + for (k in attr.keys) { + if (k == UseCaseAttributes.ID.key) { + return node + } + } + node = node.parent + } + + checkNotNull(node) { + "Can't find use case in parents for requirement #${requirementNumber}".also { + logger.error { it } + } + } + + } + + //endregion + + //region Use case + + private fun processUseCase(block: StructuralNode) { + val strUseCaseId = block.attributes[UseCaseAttributes.ID.key].toString() + val strTitle = block.title + val strAnchor = block.id + + val specBlocks: MutableList = mutableListOf() + gatherUseCaseBlocks(block, specBlocks) + + val backgroundContent: MutableList = mutableListOf() + val scenarios: MutableList = mutableListOf() + var iBlock = 0 + while (iBlock < specBlocks.count()) { + val useCaseBlock = specBlocks[iBlock] + if (useCaseBlock.hasRole(RoleNames.UseCase.BACKGROUND.key)) { + backgroundContent.addAll(getSteps(useCaseBlock)) + } + + if (useCaseBlock.hasRole(RoleNames.UseCase.SCENARIO.key)) { + val oTitle = useCaseBlock.attributes["sdpi_scenario"] + checkNotNull(oTitle) + { + "${getLocation(useCaseBlock)} missing required scenario title".also { logger.error { it } } + } + + val iStepBlock = iBlock + 1 + check(iStepBlock < specBlocks.count() && specBlocks[iStepBlock].hasRole(RoleNames.UseCase.STEPS.key)) + { + "${getLocation(useCaseBlock)} missing steps for scenario $oTitle".also { logger.error { it } } + } + val stepBlock = specBlocks[iStepBlock] + val scenarioSteps = getSteps(stepBlock) + scenarios.add(UseCaseScenario(oTitle.toString(), scenarioSteps)) + } + + + ++iBlock + } + + val spec = UseCaseSpecification(backgroundContent, scenarios) + useCases[strUseCaseId] = SdpiUseCase(strUseCaseId, strTitle, strAnchor, spec) + } + + private fun gatherUseCaseBlocks(block: StructuralNode, specBlocks: MutableList) { + for (child in block.blocks) { + val strRole = child.role + when (strRole) { + RoleNames.UseCase.BACKGROUND.key -> specBlocks.add(child) + RoleNames.UseCase.SCENARIO.key -> { + specBlocks.add(child) + gatherUseCaseBlocks(child, specBlocks) + } + + RoleNames.UseCase.STEPS.key-> specBlocks.add(child) + else -> gatherUseCaseBlocks(child, specBlocks) + } + } + } + + private fun getSteps(block: StructuralNode): List { + val steps: MutableList = mutableListOf() + + val reType = Regex("[*](?[a-zA-Z]+)[*]\\s+(?.*)") + for (child in block.blocks) { + check(child is org.asciidoctor.ast.Block) + { + "${getLocation(child)} steps must be paragraphs".also { logger.error { it } } + } + for (strLine in child.lines) { + val mType = reType.find(strLine) + checkNotNull(mType) + { + "${getLocation(child)} step invalid format".also { logger.error { it } } + } + + val oType = mType.groups["type"]?.value + checkNotNull(oType) + { + "${getLocation(child)} step missing type".also { logger.error { it } } + } + + val oDescription = mType.groups["description"]?.value + checkNotNull(oDescription) + { + "${getLocation(child)} step missing description".also { logger.error { it } } + } + + val stepType = resolveStepType(oType.toString()) + checkNotNull(stepType) + { + "${getLocation(child)} invalid step type".also { logger.error { it } } + } + + steps.add(GherkinStep(stepType, oDescription.toString())) + } + } + + return steps + } + + //endregion + +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/Bibliography.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/Bibliography.kt new file mode 100644 index 00000000..09d558d5 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/Bibliography.kt @@ -0,0 +1,3 @@ +package org.sdpi.asciidoc.model + +data class BibliographyEntry(val reference : String, val referenceText : String, val source : String) diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiRequirement.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiRequirement.kt index e8184789..a106cb22 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiRequirement.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiRequirement.kt @@ -1,36 +1,282 @@ package org.sdpi.asciidoc.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.sdpi.asciidoc.* +import org.sdpi.asciidoc.Attributes +import org.sdpi.asciidoc.BlockAttribute +import org.sdpi.asciidoc.title /** * Definition of requirement levels. * * @property keyword The keyword as it is supposed to appear as a value to the attribute key. */ -enum class RequirementLevel(val keyword: String) { - MAY("may"), - SHOULD("should"), - SHALL("shall") +enum class RequirementLevel(val keyword: String, val icsStatus: String) { + MAY("may", "p"), + SHOULD("should", "r"), + SHALL("shall", "m") +} + +/** + * Define requirement types. See SDPi Requirements Core Model [[SDPi:§1:A.4.1]] + * @property keyword: the ascii doc attribute key. + * @property description: friendly label for the requirement type (e.g., to + * describe types in tables). + */ +enum class RequirementType(val keyword: String, val description: String) { + // A requirement in a high-level, profile-independent use case specification + USE_CASE("use_case_feature", "Use case feature"), + + // A basic technical requirement that is not one of the other categories + // and does not require additional metadata. + TECH("tech_feature", "Technical feature"), + + // Requirement associated to an aspect of an IHE profile specification + IHE_PROFILE("ihe_profile", "IHE profile"), + + // A requirement linked to an implementation conformance statement in + // a referenced standard + REF_ICS("ref_ics", "Referenced implementation conformance statement"), + + // Requirement that represents a quality aspect of the specification typically + // related to risk management activities and resulting mitigations + RISK_MITIGATION("risk_mitigation", "Risk management and mitigation"), +} + +enum class RiskMitigationType(val keyword: String, val description: String) { + GENERAL("general", "General risk mitigation"), + SAFETY("safety", "Safety risk mitigation"), + EFFECTIVENESS("effectiveness", "Effectiveness risk mitigation"), + SECURITY("security", "Security risk mitigation"), + AUDIT("audit", "Traceability risk mitigation"), +} + +enum class RiskMitigationTestability(val keyword: String, val description: String) { + INSPECTION("inspect", "Verification requires behaviour inspection"), + INTEROPERABILITY("wire", "Verification can be done by a test tool") } /** * Structure that represents an SDPi requirement. * * @property number The requirement number as an integer (no leading R or zero-padding). + * @property globalId The requirement id as a globally unique oid. + * @property type The type of requirement. * @property level The requirement level as specified by the attribute [BlockAttribute.REQUIREMENT_LEVEL]. * @property maxOccurrence The number of occurrences in the document (some requirements are copy-pasted for sake of readability) * @property asciiDocAttributes All attributes captured by the block that represents this requirement. * @property asciiDocLines The actual ASCIIdoc source. + * @property blockId Identifier for the block, e.g., for linking + * @property localId The local requirement id (e.g., R1010) + * @property blockTitle The title used for the block defining the requirement. */ @Serializable data class SdpiRequirement( val number: Int, + val globalId: String, + val type: RequirementType, val level: RequirementLevel, + val groups: List, val maxOccurrence: Int, val asciiDocAttributes: Attributes, val asciiDocLines: List ) { - val blockId = asciiDocAttributes.id() + val blockId = globalId + val localId = String.format("R%04d", number) val blockTitle = asciiDocAttributes.title() -} \ No newline at end of file + + fun makeLink(): String { + return "link:#$blockId[$localId]" + } +} + +@Serializable +data class RequirementSpecification( + val normativeContent: List, + val noteContent: List, + val exampleContent: List, + val relatedContent: List +) + +@Serializable +sealed class SdpiRequirement2 { + abstract val requirementNumber: Int + abstract val localId: String + abstract val globalId: String + abstract val level: RequirementLevel + abstract val groups: List + abstract val specification: RequirementSpecification + + /** + * Human friendly description of the requirement type. + */ + abstract fun getTypeDescription(): String + + fun makeLink(): String { + return "link:#${getBlockId()}[${localId}]" + } + + fun makeLinkGlobal(): String { + if (globalId.isNotEmpty()) { + return "link:#${getBlockId()}[${globalId}]" + } + return makeLink() + } + + fun getBlockId(): String { + if (globalId.isNotEmpty()) { + return globalId + } + + return String.format("r%04d", requirementNumber) + } + + @Serializable + @SerialName("tech-feature") + data class TechFeature( + override val requirementNumber: Int, + override val localId: String, + override val globalId: String, + override val level: RequirementLevel, + override val groups: List, + override val specification: RequirementSpecification + ) : SdpiRequirement2() { + override fun getTypeDescription(): String { + return RequirementType.TECH.description + } + } + + + @Serializable + @SerialName("use-case") + data class UseCase( + override val requirementNumber: Int, + override val localId: String, + override val globalId: String, + override val level: RequirementLevel, + override val groups: List, + override val specification: RequirementSpecification, + val useCaseId: String + ) : SdpiRequirement2() { + override fun getTypeDescription(): String { + return RequirementType.USE_CASE.description + } + } + + + @Serializable + @SerialName("ref-ics") + data class ReferencedImplementationConformanceStatement( + override val requirementNumber: Int, + override val localId: String, + override val globalId: String, + override val level: RequirementLevel, + override val groups: List, + override val specification: RequirementSpecification, + val referenceId: String, + val referenceSource: String, + val referenceSection: String, + val referenceRequirement: String + ) : SdpiRequirement2() { + override fun getTypeDescription(): String { + return RequirementType.REF_ICS.description + } + } + + @Serializable + @SerialName("risk-mitigation") + data class RiskMitigation( + override val requirementNumber: Int, + override val localId: String, + override val globalId: String, + override val level: RequirementLevel, + override val groups: List, + override val specification: RequirementSpecification, + val mitigating: RiskMitigationType, + val test: RiskMitigationTestability + ) : SdpiRequirement2() { + override fun getTypeDescription(): String { + return RequirementType.RISK_MITIGATION.description + } + } + +} + + +/** + * Storage for content elements (e.g., requirement normative statements, + * notes, etc). Supports writing to json format. + */ +@Serializable +sealed class Content { + abstract fun countKeyword(strKeyword: String): Int + + @Serializable + @SerialName("block") + data class Block(val lines: List) : Content() { + override fun countKeyword(strKeyword: String): Int { + val searcher = Regex(strKeyword) + var nCount = 0 + for (str in lines) { + nCount += searcher.findAll(str).count() + } + + return nCount + } + } + + @Serializable + @SerialName("listing") + data class Listing(val title: String, val lines: List) : Content() { + override fun countKeyword(strKeyword: String): Int { + val searcher = Regex(strKeyword) + var nCount: Int = searcher.findAll(title).count() + for (str in lines) { + nCount += searcher.findAll(str).count() + } + + return nCount + } + } + + @Serializable + @SerialName("olist") + data class OrderedList(val items: List) : Content() { + override fun countKeyword(strKeyword: String): Int { + var nCount = 0 + for (item in items) { + nCount += item.countKeyword(strKeyword) + } + return nCount + } + } + + @Serializable + @SerialName("ulist") + data class UnorderedList(val items: List) : Content() { + override fun countKeyword(strKeyword: String): Int { + var nCount = 0 + for (item in items) { + nCount += item.countKeyword(strKeyword) + } + return nCount + } + } + + @Serializable + @SerialName("item") + data class ListItem(val strMarker: String, val strText: String) : Content() { + override fun countKeyword(strKeyword: String): Int { + val searcher = Regex(strKeyword) + return searcher.findAll(strText).count() + } + } +} + +fun countKeyword(strKeyword: String, contents: List): Int { + var nCount = 0 + for (content in contents) { + nCount += content.countKeyword(strKeyword) + } + return nCount +} diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiUseCase.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiUseCase.kt new file mode 100644 index 00000000..473a3df4 --- /dev/null +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/SdpiUseCase.kt @@ -0,0 +1,44 @@ +package org.sdpi.asciidoc.model + +import kotlinx.serialization.Serializable + +enum class GherkinStepType(val keyword: String) { + GIVEN("given"), + WHEN("when"), + THEN("then"), + AND("and"), + OR("or"), +} + +/** + * Takes a string and converts it to a [GherkinStepType] enum. + * + * @param raw Raw text being shall, should or may. + * + * @return the [GherkinStepType] enum or null if the conversion failed. + */ +fun resolveStepType(raw: String) = GherkinStepType.entries.firstOrNull { it.keyword == raw.lowercase() } + + +@Serializable +data class GherkinStep(val step: GherkinStepType, val description: String) + +@Serializable +data class UseCaseScenario( + val title: String, + val specification: List, +) + +@Serializable +data class UseCaseSpecification( + val background: List, + val scenarios: List, +) + +@Serializable +data class SdpiUseCase( + val id: String, + val title: String, + val anchor: String, + val specification: UseCaseSpecification, +) \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/StructuralNodeWrapper.kt b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/StructuralNodeWrapper.kt index dc5cc52d..dcc3581e 100644 --- a/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/StructuralNodeWrapper.kt +++ b/.ci/asciidoc-converter/src/main/kotlin/org/sdpi/asciidoc/model/StructuralNodeWrapper.kt @@ -20,6 +20,7 @@ fun StructuralNode.toSealed(): StructuralNodeWrapper { }?.let { StructuralNodeWrapper.SdpiRequirement(this as Block) } ?: StructuralNodeWrapper.Sidebar(this as Block) + else -> StructuralNodeWrapper.Unknown } } @@ -32,10 +33,10 @@ sealed class StructuralNodeWrapper { data class Document(val wrapped: org.asciidoctor.ast.Document) : StructuralNodeWrapper() data class Sidebar(val wrapped: Block) : StructuralNodeWrapper() data class SdpiRequirement(val wrapped: Block) : StructuralNodeWrapper() - data class Paragraph(val wrapped: Block): StructuralNodeWrapper() - data class Image(val wrapped: Block): StructuralNodeWrapper() - data class Table(val wrapped: org.asciidoctor.ast.Table): StructuralNodeWrapper() - data class Listing(val wrapped: Block): StructuralNodeWrapper() + data class Paragraph(val wrapped: Block) : StructuralNodeWrapper() + data class Image(val wrapped: Block) : StructuralNodeWrapper() + data class Table(val wrapped: org.asciidoctor.ast.Table) : StructuralNodeWrapper() + data class Listing(val wrapped: Block) : StructuralNodeWrapper() object Unknown : StructuralNodeWrapper() } diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/BlockNumberingTest.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/BlockNumberingTest.kt new file mode 100644 index 00000000..880a7e72 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/BlockNumberingTest.kt @@ -0,0 +1,94 @@ +package org.sdpi + +import org.asciidoctor.Asciidoctor +import org.asciidoctor.Options +import org.asciidoctor.SafeMode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.sdpi.asciidoc.extension.AnchorReplacementsMap +import org.sdpi.asciidoc.extension.NumberingProcessor +import java.io.ByteArrayOutputStream + +/** + * Check that block levels are correctly renumbered by the numbering tree + * processor and that the expected html output is generated. This is so + * content authors can indicate where document from the supplement will + * be inserted into the Technical Framework document. + */ +internal class BlockNumberingTest { + /** + * Check heading numbers are correctly applied, following heading offsets + * specified in the input. + */ + @Test + fun testOffset() { + val testInputResourceName = "test_offset_input.adoc" + val expectedStructureResourceName = "test_offset_expected_structure.txt" + runNumberingProcessorTest(testInputResourceName, expectedStructureResourceName) + } + + /** + * Test html output generation from an AsciiDoc source file that includes + * heading offsets that need to be applied to heading numbering. + */ + @Test + fun testOffsetDocGeneration() { + runDocumentGenerationTest("test_offset_input") + } + + /** + * Check heading numbers are correctly applied, following relative levels + * specified in the input. + */ + @Test + fun testLevels() { + val testInputResourceName = "test_level_input.adoc" + val expectedStructureResourceName = "test_level_expected_structure.txt" + runNumberingProcessorTest(testInputResourceName, expectedStructureResourceName) + } + + /** + * Test html output generation from an AsciiDoc source file that includes + * heading levels that need to be applied to heading numbering. + */ + @Test + fun testLevelsDocGeneration() { + runDocumentGenerationTest("test_level_input") + } + + private fun runNumberingProcessorTest(testInputResourceName: String, expectedStructureResourceName: String) { + val input = readResource(testInputResourceName) + val expectedOutput = readResource(expectedStructureResourceName) + val actualOutput = ByteArrayOutputStream(expectedOutput.toByteArray().size) + + val options = Options.builder() + .safe(SafeMode.UNSAFE) + .backend("html") + .build() + + val asciidoctor = Asciidoctor.Factory.create() + + val anchorReplacements = AnchorReplacementsMap() + asciidoctor.javaExtensionRegistry().treeprocessor( + NumberingProcessor(actualOutput, anchorReplacements), + ) + + asciidoctor.convert(input, options) + + // Windows and Linux use different line endings but the output uses + // \r line endings on Windows. Resolve this by normalizing both strings. + val normalizedExpected = expectedOutput.lines().joinToString(System.lineSeparator()) + val normalizedOutput = actualOutput.toString(Charsets.UTF_8).lines().joinToString(System.lineSeparator()) + assertEquals(normalizedExpected, normalizedOutput) + } + + private fun readResource(strResourceName: String): String { + return javaClass.classLoader.getResourceAsStream(strResourceName)?.reader()?.readText() + ?: throw Exception("Read failed") + } + + private fun runDocumentGenerationTest(strTestFile: String) { + val engine = TestRunner(strTestFile) + engine.performTest() + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertAndVerifySupplementTest.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertAndVerifySupplementTest.kt deleted file mode 100644 index 39a3e5fb..00000000 --- a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertAndVerifySupplementTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.sdpi - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import java.io.ByteArrayOutputStream -import java.nio.file.Files - -internal class ConvertAndVerifySupplementTest { - @Test - fun testOffset() { - val testInputResourceName = "test_offset_input.adoc" - val expectedStructureResourceName = "test_offset_expected_structure.txt" - performTest(testInputResourceName, expectedStructureResourceName) - } - - @Test - fun testLevel() { - val testInputResourceName = "test_level_input.adoc" - val expectedStructureResourceName = "test_level_expected_structure.txt" - performTest(testInputResourceName, expectedStructureResourceName) - } - - private fun performTest(testInputResourceName: String, expectedStructureResourceName: String) { - val expectedOutput = - javaClass.classLoader.getResourceAsStream(expectedStructureResourceName)?.reader()?.readText() - ?: throw Exception("Read failed") - val actualOutput = ByteArrayOutputStream(expectedOutput.toByteArray().size) - - Files.createTempFile("asciidoc-converter-test", ".tmp").toFile().also { - AsciidocConverter( - AsciidocConverter.Input.StringInput( - javaClass.classLoader.getResourceAsStream(testInputResourceName)?.reader()?.readText() - ?: throw Exception("Read failed") - ), - it, - AsciidocConverter.Mode.Test(actualOutput) - ).run() - }.also { - it.delete() - } - - assertEquals(expectedOutput, actualOutput.toString(Charsets.UTF_8)) - } -} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertRequirementsTest.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertRequirementsTest.kt new file mode 100644 index 00000000..eacced14 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertRequirementsTest.kt @@ -0,0 +1,74 @@ +package org.sdpi + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.file.Files + +/** + * Tests html output generation for semantic requirements in + * an AsciiDoc source file. + */ +internal class ConvertRequirementsTest { + + /** + * Minimal technical requirement. + */ + @Test + fun testMinimumRequirement() { + performTest("min_requirement") + } + + /** + * Complete technical requirement including normative, note, + * example and related sections. + */ + @Test + fun testFullRequirement() { + performTest("full_requirement") + } + + /** + * Requirement that references another standard. + */ + @Test + fun testRefIcsRequirement() { + performTest("ref_ics_requirement") + } + + /** + * Risk mitigation requirement. + */ + @Test + fun testRiskRequirement() { + performTest("risk_requirement") + } + + /** + * Requirement within a use case; the use-case id is discovered automatically. + */ + @Test + fun testUseCaseRequirement() { + performTest("use_case_requirement") + } + + /** + * No test for this one because I can't figure out what it is for. + */ + fun testIheRequirement() { + + } + + /** + * Source includes a reference to the requirement before and after + * the requirement definition. + */ + @Test + fun testRequirementRef() { + performTest("cross_ref_requirement") + } + + private fun performTest(strTestFile: String) { + val engine = TestRunner(strTestFile) + engine.performTest() + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertUseCasesTest.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertUseCasesTest.kt new file mode 100644 index 00000000..8ad00c06 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/ConvertUseCasesTest.kt @@ -0,0 +1,22 @@ +package org.sdpi + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.nio.file.Files + +/** + * Tests html output generation for semantic use cases + * specified in an AsciiDoc file. + */ +internal class ConvertUseCasesTest { + + @Test + fun useCaseWithCrossRef() { + performTest("use_case") + } + + private fun performTest(strTestFile: String) { + val engine = TestRunner(strTestFile) + engine.performTest() + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/IcsTableGenerationTests.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/IcsTableGenerationTests.kt new file mode 100644 index 00000000..b68ef537 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/IcsTableGenerationTests.kt @@ -0,0 +1,24 @@ +package org.sdpi + +import org.junit.jupiter.api.Test + +/** + * Test generation of ics tables of requirements + */ +class IcsTableGenerationTests { + + @Test + fun tableNoFilter() { + performTest("ics_no_filter") + } + + @Test + fun tableFiltered() { + performTest("ics_filtered") + } + + private fun performTest(strTestFile: String) { + val engine = TestRunner(strTestFile) + engine.performTest() + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/TestRunner.kt b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/TestRunner.kt new file mode 100644 index 00000000..8dcdfc7a --- /dev/null +++ b/.ci/asciidoc-converter/src/test/kotlin/org/sdpi/TestRunner.kt @@ -0,0 +1,40 @@ +package org.sdpi + +import org.junit.jupiter.api.Assertions.assertEquals +import java.nio.file.Files + +internal class TestRunner(private val strTestFile: String) { + + fun performTest() { + + val strSourceResource = "$strTestFile.adoc" + val strExpectedOutputResource = "$strTestFile.html" + + val strExpectedOutput = readFileContents(strExpectedOutputResource) + val strInput = readFileContents(strSourceResource) + + val tempOutputFile = Files.createTempFile("asciidoc-converter-test", ".tmp").toFile() + val converter = + AsciidocConverter( + AsciidocConverter.Input.StringInput(strInput), tempOutputFile.outputStream(), + ConverterOptions(generateTestOutput = true), + ) + + converter.run() + + val strActualOutput = tempOutputFile.reader().readText().trim() + + // Windows and Linux use different line endings but the output uses + // \r line endings on Windows. Resolve this by normalizing both strings. + val normalizedExpected = strExpectedOutput.lines().joinToString(System.lineSeparator()) + val normalizedOutput = strActualOutput.lines().joinToString(System.lineSeparator()) + assertEquals(normalizedExpected, normalizedOutput) + + tempOutputFile.delete() + } + + private fun readFileContents(strPath: String): String { + return javaClass.classLoader.getResourceAsStream(strPath)?.reader()?.readText()?.trim() + ?: throw Exception("Read failed") + } +} \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/resources/cross_ref_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/cross_ref_requirement.adoc new file mode 100644 index 00000000..a1e5172a --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/cross_ref_requirement.adoc @@ -0,0 +1,18 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 + +Lorem ipsum dolor sit amet, RefRequirement:r1021[] consectetur adipiscing elit. + +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS provider, then it shall use the DEV-46 transaction to update the discovery proxy actor of its network presence and departure. +==== + +**** + +Cras at nunc ut metus elementum feugiat. Suspendisse RefRequirement:r1021[] tempor viverra ex a pharetra. \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/resources/full_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/full_requirement.adoc new file mode 100644 index 00000000..c8ec2873 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/full_requirement.adoc @@ -0,0 +1,37 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 + +.R1023 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="consumer,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS consumer, then it shall use the DEV-47 transaction to retrieve SOMDS provider network presence metadata from the Discovery Proxy actor. +==== + +[NOTE] +==== +. When retrieving network presence metadata from a discovery proxy actor, a discovery scope may be specified as a filter to identify a specific subset of SOMDS provider systems. +. A SOMDS consumer may optionally use the DEV-47 transaction to subscribe to all metadata updates from a set of SOMDS consumer systems, essentially using the discovery proxy as a pass-through for SOMDS provider DEV-23 and DEV-24 transactions. +==== + +[EXAMPLE] +==== +This is an example of an example block. +==== + +[RELATED] +==== +<>, section 2.2.2 Managed mode. +==== + +**** + +[bibliography] +=== Referenced Standards + +* [[[ref_ieee_11073_10101_2020,IEEE 11073-10101:2020]]] IEEE 11073-10101™ International Standard - Health informatics--Device interoperability--Part 10101:Point-of-care medical device communication--Nomenclature. Available at https://standards.ieee.org/ieee/11073-10101/10343/[IEEE online standards store]. + +* [[[ref_oasis_ws_discovery_2009,OASIS WS-Discovery:2009]]] OASIS Standard, Web Services Dynamic Discovery (WS-Discovery) Version 1.1, OASIS Standard, 1 July 2009, available at http://docs.oasis-open.org/ws-dd/discovery/1.1/wsdd-discovery-1.1-spec.html diff --git a/.ci/asciidoc-converter/src/test/resources/ics_filtered.adoc b/.ci/asciidoc-converter/src/test/resources/ics_filtered.adoc new file mode 100644 index 00000000..07c04819 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/ics_filtered.adoc @@ -0,0 +1,46 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 +:sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + +== ICS Table + +Requirements for providers: + +// The table can be present before requirements are defined. +sdpi_ics_table::[sdpi_req_group=consumer] + +== Requirements + +.R1005 +[sdpi_requirement,sdpi_req_level=should,sdpi_req_type=risk_mitigation,sdpi_ses_type=safety,sdpi_ses_test=wire,sdpi_req_group=consumer,sdpi_req_specification=sdpi] +**** + +[NORMATIVE] +==== +A SOMDS Consumer should reconnect or go into a fail-safe mode when it receives a report with an MDIB version that is either lower than the last received version or more than one version higher than the last received version. +==== + +**** + +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="provider,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS provider, then it shall use the DEV-46 transaction to update the discovery proxy actor of its network presence and departure. +==== + +**** + +.R1023 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="consumer,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS consumer, then it shall use the DEV-47 transaction to retrieve SOMDS provider network presence metadata from the Discovery Proxy actor. +==== + +**** diff --git a/.ci/asciidoc-converter/src/test/resources/ics_no_filter.adoc b/.ci/asciidoc-converter/src/test/resources/ics_no_filter.adoc new file mode 100644 index 00000000..752f80f7 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/ics_no_filter.adoc @@ -0,0 +1,44 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 +:sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + +== ICS Table + +// The table can be present before requirements are defined. +sdpi_ics_table::[] + +== Requirements + +.R1005 +[sdpi_requirement,sdpi_req_level=should,sdpi_req_type=risk_mitigation,sdpi_ses_type=safety,sdpi_ses_test=wire,sdpi_req_group=consumer,sdpi_req_specification=sdpi] +**** + +[NORMATIVE] +==== +A SOMDS Consumer should reconnect or go into a fail-safe mode when it receives a report with an MDIB version that is either lower than the last received version or more than one version higher than the last received version. +==== + +**** + +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="provider,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS provider, then it shall use the DEV-46 transaction to update the discovery proxy actor of its network presence and departure. +==== + +**** + +.R1023 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="consumer,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS consumer, then it shall use the DEV-47 transaction to retrieve SOMDS provider network presence metadata from the Discovery Proxy actor. +==== + +**** diff --git a/.ci/asciidoc-converter/src/test/resources/min_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/min_requirement.adoc new file mode 100644 index 00000000..20824a9f --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/min_requirement.adoc @@ -0,0 +1,14 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 + +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS provider, then it shall use the DEV-46 transaction to update the discovery proxy actor of its network presence and departure. +==== + +**** diff --git a/.ci/asciidoc-converter/src/test/resources/ref_ics_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/ref_ics_requirement.adoc new file mode 100644 index 00000000..89a6bbac --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/ref_ics_requirement.adoc @@ -0,0 +1,22 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 +:sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=ref_ics,sdpi_ref_id=ref_oasis_ws_discovery_2009,sdpi_ref_section=4,sdpi_ref_req=RR0000,sdpi_req_specification=sdpi] +**** + +[NORMATIVE] +==== +When the Managed Discovery Option is enabled for a SOMDS Provider Actor, then it shall use the DEV-46 transaction to update the Discovery Proxy Actor on its network presence and departure. +==== + +**** + +[bibliography] +=== Referenced Standards + +* [[[ref_ieee_11073_10101_2020,IEEE 11073-10101:2020]]] IEEE 11073-10101™ International Standard - Health informatics--Device interoperability--Part 10101:Point-of-care medical device communication--Nomenclature. Available at https://standards.ieee.org/ieee/11073-10101/10343/[IEEE online standards store]. + +* [[[ref_oasis_ws_discovery_2009,OASIS WS-Discovery:2009]]] OASIS Standard, Web Services Dynamic Discovery (WS-Discovery) Version 1.1, OASIS Standard, 1 July 2009, available at http://docs.oasis-open.org/ws-dd/discovery/1.1/wsdd-discovery-1.1-spec.html diff --git a/.ci/asciidoc-converter/src/test/resources/risk_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/risk_requirement.adoc new file mode 100644 index 00000000..f8ffe20a --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/risk_requirement.adoc @@ -0,0 +1,15 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 +:sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + +.R1005 +[sdpi_requirement,sdpi_req_level=should,sdpi_req_type=risk_mitigation,sdpi_ses_type=safety,sdpi_ses_test=wire,sdpi_req_specification=sdpi] +**** + +[NORMATIVE] +==== +A SOMDS Consumer should reconnect or go into a fail-safe mode when it receives a report with an MDIB version that is either lower than the last received version or more than one version higher than the last received version. +==== + +**** \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/resources/test_level_expected_structure.txt b/.ci/asciidoc-converter/src/test/resources/test_level_expected_structure.txt index 0dbd26e4..eccbe438 100644 --- a/.ci/asciidoc-converter/src/test/resources/test_level_expected_structure.txt +++ b/.ci/asciidoc-converter/src/test/resources/test_level_expected_structure.txt @@ -20,7 +20,7 @@ Level 7 3.1.1.1.1.5 Level 6 3.1.1.1.1.6 Level 6 3.1.1.1.1.6.1 Level 7 -Appendix A: Level 1 +Appendix A Level 1 A.1 Level 2 A.1.1 Level 3 A.1.1.1 Level 4 diff --git a/.ci/asciidoc-converter/src/test/resources/test_level_input.adoc b/.ci/asciidoc-converter/src/test/resources/test_level_input.adoc index b92a8504..4463143d 100644 --- a/.ci/asciidoc-converter/src/test/resources/test_level_input.adoc +++ b/.ci/asciidoc-converter/src/test/resources/test_level_input.adoc @@ -52,7 +52,7 @@ [sdpi_level=+2] ====== Level 7 -[appendix] +[appendix,sdpi_offset=A] == Level 1 === Level 2 diff --git a/.ci/asciidoc-converter/src/test/resources/test_offset_expected_structure.txt b/.ci/asciidoc-converter/src/test/resources/test_offset_expected_structure.txt index 89d9eff1..1db949d9 100644 --- a/.ci/asciidoc-converter/src/test/resources/test_offset_expected_structure.txt +++ b/.ci/asciidoc-converter/src/test/resources/test_offset_expected_structure.txt @@ -19,11 +19,11 @@ Part 1 Chapter Section 10.3.2 Part 1 Chapter 10 Section 3 Sub-Section 2 10.3.3 Part 1 Chapter 4 Section 3 Sub-Section 3 Part 2 -Appendix A: Part 2 Appendix A +Appendix A Part 2 Appendix A A.1 Part 2 Appendix A Section 1 A.2 Part 2 Appendix A Section 2 -Appendix B: Part 2 Appendix B -Appendix X: Part 2 Appendix X +Appendix B Part 2 Appendix B +Appendix X Part 2 Appendix X X.1 Part 2 Appendix X Section 1 X.2 Part 2 Appendix X Section 1 -Appendix Y: Part 2 Appendix Y +Appendix Y Part 2 Appendix Y diff --git a/.ci/asciidoc-converter/src/test/resources/test_offset_input.adoc b/.ci/asciidoc-converter/src/test/resources/test_offset_input.adoc index ff71c97b..46687a17 100644 --- a/.ci/asciidoc-converter/src/test/resources/test_offset_input.adoc +++ b/.ci/asciidoc-converter/src/test/resources/test_offset_input.adoc @@ -46,7 +46,7 @@ = Part 2 -[appendix] +[appendix,sdpi_offset=A] == Part 2 Appendix A === Part 2 Appendix A Section 1 diff --git a/.ci/asciidoc-converter/src/test/resources/use_case.adoc b/.ci/asciidoc-converter/src/test/resources/use_case.adoc new file mode 100644 index 00000000..09dd6595 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/use_case.adoc @@ -0,0 +1,53 @@ +:doctype: book + +Pellentesque pellentesque ligula vitae neque RefUseCase:stad[] porttitor sollicitudin. Nullam at nisi nunc. + +[role="use-case",sdpi_use_case_id=stad] +[sdpi_feature="Synchronized Time Across Devices"] +=== Use Case Feature 1: Synchronized time across devices (STAD) + +==== Narrative +Nurse Jean attaches a ventilator to the medical device network in the ICU. It automatically obtains the correct time. + +==== Benefits +Automatically acquiring the time saves the user from spending time entering the time into the device. It also guarantees that the correct time will be entered. +It is also important for all devices to have a consistent time since the data being exported to consuming devices and systems will use the time stamps from the device to mark the time that the clinical data was acquired. Since this is part of the clinical record, accuracy is very important. + +==== Technical Pre-Conditions + +[role=use-case-background] +==== +*Given* All devices communicate using a common acronym_md_lan protocol + +*And* A Time Source (TS) Service is on the acronym_md_lan network +==== + +==== Scenarios + +[role=use-case-scenario,sdpi_scenario="Device is connected to the MD LAN network with a Time Source service"] +===== Scenario: STAD 1.1 --- Device is connected to the MD LAN network with a Time Source service + +[role=use-case-steps] +==== +*Given* Device has detected at least one TS Service + +*When* The TS Service is operational + +*Then* The device will synchronize its time with the TS Service +==== + +[role=use-case-scenario,sdpi_scenario="User wants to change the time of a device connected to the MD LAN network"] +===== Scenario: STAD 1.2 --- Device is connected to the MD LAN network and a user wants to change the device’s time + +[role=use-case-steps] +==== +*Given* Device is operational in MD LAN network + +*When* The user attempts to change the time on the device manually + +*Then* The device will disable the ability to change its time manually +==== + + + +Cras gravida mi nisl, id RefUseCase:stad[] tincidunt odio sollicitudin ut. Aliquam erat volutpat. \ No newline at end of file diff --git a/.ci/asciidoc-converter/src/test/resources/use_case_requirement.adoc b/.ci/asciidoc-converter/src/test/resources/use_case_requirement.adoc new file mode 100644 index 00000000..7907d8c9 --- /dev/null +++ b/.ci/asciidoc-converter/src/test/resources/use_case_requirement.adoc @@ -0,0 +1,62 @@ +:doctype: book + +:sdpi_oid.sdpi-p: 1.3.6.1.4.1.19376.1.6.2.11 +:sdpi_oid.sdpi: 1.3.6.1.4.1.19376.1.6.2.10.1.1.1 + +[role="use-case",sdpi_use_case_id=stad] +[sdpi_feature="Synchronized Time Across Devices"] +=== Use Case Feature 1: Synchronized Time Across Devices (STAD) + +==== Narrative +Nurse Jean attaches a ventilator to the medical device network in the ICU. It automatically obtains the correct time. + +==== Benefits +Automatically acquiring the time saves the user from spending time entering the time into the device. It also guarantees that the correct time will be entered. +It is also important for all devices to have a consistent time since the data being exported to consuming devices and systems will use the time stamps from the device to mark the time that the clinical data was acquired. Since this is part of the clinical record, accuracy is very important. + +==== Technical Pre-Conditions + +[role=use-case-background] +==== +*Given* All devices communicate using a common MD LAN protocol + +*And* A Time Source (TS) Service is on the MD LAN network +==== + +==== Scenarios + +[role=use-case-scenario] +[sdpi_scenario="Device is connected to the MD LAN network with a Time Source service"] +===== Scenario: STAD 1.1 - Device is connected to the MD LAN network with a Time Source service + +[role=use-case-steps] +==== +*Given* Device has detected at least one TS Service + +*When* The TS Service is operational + +*Then* The device will synchronize its time with the TS Service +==== + +====== Safety, Effectiveness and Security - Requirements and Considerations + +.R1520 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=use_case_feature,sdpi_req_specification=sdpi] +[sdpi_req_group="consumer,provider"] +**** + +[NORMATIVE] +==== +The Manufacturer of a SOMDS Participant shall include all of the following information in the accompanying documentation: + + * The responsible organization needs to provide a TS Service with 50 millisecond accuracy. + * The responsible organization needs to provide a redundant TS Service configuration with at least one backup server. + * The responsible organization needs to configure the same TS Service for SOMDS Participants that execute System Function Contribution (SFC)s together. +==== + +[NOTE] +==== +The 50ms target accuracy is suitable for highly demanding use cases like real time waveform comparison. +==== + +**** \ No newline at end of file diff --git a/articles/sdpi-article-ihe-tf-asciidoc-cookbook.adoc b/articles/sdpi-article-ihe-tf-asciidoc-cookbook.adoc index ca6d715f..a6464701 100644 --- a/articles/sdpi-article-ihe-tf-asciidoc-cookbook.adoc +++ b/articles/sdpi-article-ihe-tf-asciidoc-cookbook.adoc @@ -224,23 +224,19 @@ Check the AsciiDoc source where the graphic is included to ensure that it is pro In general, AsciiDoc automatically handles other image formats based on the file name extension (e.g., myGraphic.png). -[none] -. Note: __The following path to the AsciiDoc images folder is due to the fact that this document is in the SDPi_Supplement articles folder. __ - [source] ---- -image::../asciidoc/images/ihe-logo.png[] +image::../../asciidoc/images/ihe-logo.svg[] ---- Which should render as: -WARNING: #WHY DOESN'T THIS RENDER CORRECTLY EITHER HERE OR ON THE SUPPLEMENT'S TITLE PAGE?!# +image::../../asciidoc/images/ihe-logo.svg[] -[none] -. image:../asciidoc/images/ihe-logo.png[] +NOTE: __You may need to experiment with paths to locate the image file. Use `..` for the parent folder; this one is in the `/asciidoc/images` folder. __ + +NOTE: __ Two colons "::" are needed if the graphic is on a line by itself; if it is in-line with other text, then a single colon ":" should be used.__ -[none] -. Note: __ Two colons "::" are needed if the graphic is on a line by itself; if it is in-line with other text, then a single colon ":" should be used.__ Additional information is provided on the https://docs.asciidoctor.org/asciidoc/latest/macros/image-format/[AsciiDoc Specify Image Format] page. For general file types and formats information, consult the https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types[Mozilla Image File Type and Format Guide]. @@ -341,6 +337,302 @@ For a bulleted list of items, the final "full stop" character of each non-final . Second bullet; . Final Bullet. + +== Semantic content +https://docs.asciidoctor.org/asciidoctorj/latest/[Asciidoctor], which converts these files into other formats (e.g., html, pdf), has been https://docs.asciidoctor.org/asciidoctorj/latest/extensions/extensions-introduction/[extended] to process domain specific content for the supplement. These extensions provide semantic markup of content that is used to automatically generate, for example, implementation conformance statement tables and export requirements and use cases in JSON format for external tooling (see https://profiles.ihe.net/DEV/SDPi/index.html#vol1_appendix_a_requirements_management_for_p_n_t_interperability[requirements management for plug-and-trust interoperability]). + +Content encoded and exported, to `sdpi-supplement/referenced-artifacts/` in JSON format includes: + +* `sdpi-requirements.json`: all requirements defined, as described below, in the source. +* `sdpi-use-cases.json`: all use cases defined, as described below, in the source. + + +NOTE: the extensions are written in Kotlin and may be found, along with the document processing tools, in `.ci/asciidoc-converter`. In Windows, run `build_document.bat` to process the Asciidoc source files. + +=== Defining requirements + +The example below illustrates the AsciiDoc source to define a simple requirement. + +[source,asciidoc] +---- +.R1021 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="provider,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS provider, then it SHALL use the DEV-46 transaction to update the discovery proxy actor of its network presence and departure. +==== + +**** +---- + +The block name, `sdpi_requirement`, triggers semantic processing for the requirement content while the https://docs.asciidoctor.org/asciidoc/latest/blocks/add-title/[block title] defines the requirement number. Various <> define properties for the requirement. The requirement content is delimited by exactly four *'s. The only required content for a block is the normative statement, tagged with the block name `[NORMATIVE]` and delimited by exactly four ='s. With this information we can: + +* perform basic requirement validation, such as checking level keywords in the normative text, +* generate a unique <> for each requirement, +* generate implementation conformance statement <> with a list of requirements for selected groups, +* ensure all requirement numbers are unique, +* … + +Although the normative statement is the only block needed to define a requirement, many requirements include explanatory notes, examples and related information. This additional information is captured in tagged content blocks as the example below illustrates. This semantic content is retained when the requirements are exported in JSON format. + +[source,asciidoc] +---- +.R1023 +[sdpi_requirement,sdpi_req_level=shall,sdpi_req_type=tech_feature,sdpi_req_group="consumer,discovery-proxy",sdpi_req_specification=sdpi-p] +**** + +[NORMATIVE] +==== +When the managed discovery option is enabled for a SOMDS consumer, then it shall use the DEV-47 transaction to retrieve SOMDS provider network presence metadata from the Discovery Proxy actor. +==== + +[NOTE] +==== +. When retrieving network presence metadata from a discovery proxy actor, a discovery scope may be specified as a filter to identify a specific subset of SOMDS provider systems. +. A SOMDS consumer may optionally use the DEV-47 transaction to subscribe to all metadata updates from a set of SOMDS consumer systems, essentially using the discovery proxy as a pass-through for SOMDS provider DEV-23 and DEV-24 transactions. +==== + +[EXAMPLE] +==== +This is an example of an example block. +==== + +[RELATED] +==== +<>, section 2.2.2 Managed mode. +==== + +**** +---- + +==== Requirement numbering + +Requirement numbers are parsed from the title of the `sdpi_requirement` block. They must match the regular expression `^([A-Z])*?R(\\d+)$`. That is, a sequence of upper-case characters followed by `R` then a numeric value. Examples of valid requirement titles include: `.R1002`, `.TR1002`, `.ABCDEFR1002`. Invalid requirement titles include `.r1234` (an upper-case `R` is required), `.ABC R1234` (spaces are not permitted), `.tR1234` (lower-case letters are not permitted). <> (links) to a requirement may be inserted with in-line macros. + +NOTE: The `.` character marks the line as an AsciiDoc https://docs.asciidoctor.org/asciidoc/latest/blocks/add-title/[title]; it must begin in the first column of the source file. + +NOTE: Requirements are identified by number only. They are preserved but do not form part of the requirement identifier. + +NOTE: Previously anchors were needed, for cross-referencing, when defining a requirement (e.g., `[sdpi_requirement#r1022,sdpi_req_level=shall]`). These are now generated automatically. + +[[requirement-attributes]] +==== Block attributes + +Block attributes supply metadata for the requirement. Attributes are tabulated below; additional information may be found in https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_requirement_type_requirement_definition[SDPi requirement type definitions]. + +|=== +|Attribute | Applies to | Type | Required |Description + +|`sdpi_req_type` | all | { `tech_feature` \| `ref_ics` \| `use_case_feature` \| `risk_mitigation` \| `ihe_profile` } | yes | Defines the type of requirement, from the https://profiles.ihe.net/DEV/SDPi/index. +|`sdpi_req_level` | all | {`shall` \| `should` \| `may` } |yes | Defines the behaviour expected for compatible implementations. html#vol1_clause_sdpi_requirements_core_model[SDPi requirements core model] +|`sdpi_req_group` | all | comma separated list | no | Defines membership of ad-hoc groups. Groups may be used to select related requirements when generating ICS tables. +|`sdpi_req_specification` | all | defined _specification id_| yes| Defines the root for the requirements <>. +|`sdpi_use_case_id` | `use_case_feature` | defined _use-case id_ | yes | Defines the use-case that the requirement applies to. Automatically populated from the enclosing <> section. +|`sdpi_ref_id` | `ref_ics` | defined _reference id_ | yes | The bibliography anchor for the entry to the referenced requirement. +|`sdpi_ref_section` | `ref_ics` | section identifier | yes | The identifier for the section containing the referenced requirement. +|`sdpi_ref_req` | `ref_ics` | requirement identifier | yes | The identifier for the requirement in the referenced standard. +|`sdpi_ses_type` | `risk_mitigation` | {`general` \| `safety` \| `effectiveness` \| `security` \| `audit` } | yes | https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_requirement_type_ses[Type of risk] the requirement mitigates. +|`sdpi_ses_test` | `risk_mitigation` | {`inspect` \| `wire` } | yes | Mechanism for testing the requirement. +|=== + +[[requirement-oids]] +==== ISO object identifiers + +ISO object identifiers (OIDs) are assigned and rendered with each requirement in the output file generated. They are used as the primary requirement id in implementation conformance statement tables. ISO object identifiers combine a profile- or standard-specific root identifier with requirement number, that is: `profile_oid.2.requirement_number`. The profile/standard is identified, by name, using the `sdpi_req_specification` attribute. Relationships between profile/standard names and their ISO object identifier is established using document level `sdpi_oid` scoped attributes, typically in `asciidoc/std-oid-definitions.adoc`. + +Named profile and standards include: + +|=== +| Specification (`sdpi_req_specification`) | Profile/standard + +| `sdpi` | The https://profiles.ihe.net/DEV/SDPi/index.html[SDPi] profile +| `sdpi-p` | The https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_p_profile[SDPi plug-and-trust] profile +| `sdpi-a` | The https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_a_profile[SDPi alerting] profile +| `sdpi-r` | The https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_r_profile[SDPi reporting] profile +| `sdpi-xC` | The https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_xc_profile[SDPi external control] profile +|=== + +Refer to the document source for corresponding ISO object identifiers for each profile. + +NOTE: specification names are case-sensitive. + +==== Groups + +There is currently (February 2025) no constraint on groups that may be assigned to requirements. However, to promote consistency and utility, the following groups are recommended. + +|=== +| Group id | Description + +| consumer | Applies to https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_p_somds_consumer[SOMDS consumer] implementations. +| provider | Applies to https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_p_somds_provider[SOMDS provider] implementations. +| discovery-proxy | Applies to https://profiles.ihe.net/DEV/SDPi/index.html#_discovery_proxy[managed discovery proxy] implementations. +|=== + +NOTE: Group names are case sensitive. + +[[use-case]] +=== Defining use cases + +Use case blocks, and their parts, are identified in the source by AsciiDoc https://docs.asciidoctor.org/asciidoc/latest/attributes/role/[roles], with metadata provided in block attributes. The following example AsciiDoc source defines a use-case and one scenario. + +[source,asciidoc] +---- +[role="use-case",sdpi_use_case_id=stad] +[sdpi_feature="Synchronized Time Across Devices"] +=== Use Case Feature 1: Synchronized time across devices (STAD) + +==== Narrative +Nurse Jean attaches a ventilator to the medical device network in the ICU. It automatically obtains the correct time. + +==== Benefits +Automatically acquiring the time saves the user from spending time entering the time into the device. It also guarantees that the correct time will be entered. +It is also important for all devices to have a consistent time since the data being exported to consuming devices and systems will use the time stamps from the device to mark the time that the clinical data was acquired. Since this is part of the clinical record, accuracy is very important. + +==== Technical Pre-Conditions + +[role=use-case-background] +==== +*Given* All devices communicate using a common acronym_md_lan protocol + +*And* A Time Source (TS) Service is on the acronym_md_lan network +==== + +==== Scenarios + +[role=use-case-scenario,sdpi_scenario="Device is connected to the MD LAN network with a Time Source service"] +===== Scenario: STAD 1.1 --- Device is connected to the MD LAN network with a Time Source service + +[role=use-case-steps] +==== +*Given* Device has detected at least one TS Service + +*When* The TS Service is operational + +*Then* The device will synchronize its time with the TS Service +==== + +---- + +[[use-case-roles]] +==== Block roles + +AsciiDoc https://docs.asciidoctor.org/asciidoc/latest/attributes/role/[roles] assigned to content blocks provide semantic structure to use-case definitions. The roles available for use-cases are shown in the table below. + +Along with roles, use-case blocks should be logically structured in document sections. That is, background and scenarios must be sub-sections of a use-case section; steps must be within a scenario section. This structure is used to associate each content block with the appropriate use-case/scenario. + +Each use-case must have at most one background section and zero or more scenarios. Each scenario section must have at most one steps content block (which may contain several <>). + +|=== +|Role | Parent | Content block + +| `use-case` | — | Defines a use-case feature. +| `use-case-background` | `use-case` | Defines technical pre-requisites for a use-case. +| `use-case-scenario` | `use-case` | Defines one scenario in the use-case. +| `use-case-steps` | `use-case-scenario` | Defines steps for a use-case scenario. +|=== + +[[use-case-attributes]] +==== Block attributes + +Block attributes supply metadata for the use-case. Attributes are tabulated below; additional information may be found in https://profiles.ihe.net/DEV/SDPi/index.html#vol1_clause_sdpi_requirement_type_requirement_definition[SDPi requirement type definitions]. + +|=== +|Attribute | Applies to role | Type | Required |Description + +|`sdpi_use_case_id` | `use-case` | _unique id_ | yes | Defines an id for referencing the use case in requirements and cross-referencing. Must be globally unique. +|`sdpi_use_case_scenario` | `use-case-scenario` | _unique id_ | yes | Defines an id for one sequence in a use case. Must be unique within the use-case. +|=== + +NOTE: Ids are case-sensitive. + +[[use-case-steps]] +==== Defining steps + +Steps, in background and steps content blocks, are defined in paragraphs prefixed by https://cucumber.io/docs/gherkin/reference/[Gherkin] keywords. Keywords are parsed (e.g., for exporting rich JSON data). Supported steps are shown in the table below. Steps are not case-sensitive. + +|=== +|Keyword | Description + +| *given* | Initial context of the system, typically something that happened in the past. +| *when* | An action or event that triggers the scenario. +| *then* | Expected outcome from the scenario. +| *and* | Conjunction, used with *when* or *then* for an additional trigger/outcome that is also expected. +| *or* | Conjunction, used with *when* or *then* for an alternate trigger/outcome. +|=== + +[[cross-referencing]] +=== Cross-referencing + +It is common to refer to requirements and use-cases from elsewhere in the source. Semantic https://docs.asciidoctor.org/asciidoctorj/latest/extensions/inline-macro-processor/[in-line macros] are provided to cross-reference requirements and use-cases by their requirement number and use-case id, respectively. + +|=== +|Macro name | Target | Optional attributes | Example | Description + +|RefRequirement | Requirement number | _none_ | `RefRequirement:r1002[]` | Inserts a link to the requirement target. +|RefUseCase | Use-case id | _none_ | `RefUseCase:stad[]` | Inserts a link to the use-case target. +|=== + +NOTE: use case ids are case-sensitive. + +IMPORTANT: Using the in-line macro reduces the risk of broken links and helps ensure future compatibility if, for example, <> are used to reference requirements. + +[[generate-table]] +=== Summary tables + +Summary tables are automatically generated during document processing and inserted in place of https://docs.asciidoctor.org/asciidoctorj/latest/extensions/block-macro-processor/[block macros]. The tables available are: + +|=== +|Table | Macro | Columns + +|Implementation conformance statement table | `sdpi_ics_table` | index, reference, status, support, comment +|Requirement list | `sdpi_requirement_table` | <>, local requirement id, level, type +|=== + + +NOTE: Requirement tables may be included at any point in the source (where block-macros are allowed). They will gather requirements from the entire document. It is not necessary to define requirements before they can be included in a table. + +The example below includes an ICS table in the generated document with all requirements in the `provider` group. + +[source,asciidoc] +---- +sdpi_ics_table::[sdpi_req_group=provider] +---- + +==== Filter + +Filter parameters, included as named attributes, select the content included in the table. Available filters are tabulated below. + +|=== +|Attribute name | Value | Description + +|`sdpi_req_group` | comma separated list | Includes requirements with any of the groups supplied in their `sdpi_req_group` <>. +|=== + +=== Backwards compatibility + +Some changes to the supplement source text are required to take advantage of the new features including additional attributes on requirements and tagging use cases. To ease adoption, the new extensions fallback to the original behaviour. This means, the + +. processor works with the original supplement source without any modifications, and +. output from the original supplement is largely identical following addition of new processor features as it was before the new features were added. The generated html is the same (at the byte level) except: +.. the `Build Date` entry will be different on the title page and in the footer, +.. requirement `div` blocks get an extra `requirement` style class. That is, for example, `
` instead of `
`, +.. added https://gitlab.com/antora/antora-ui-default/-/blob/master/src/js/06-copy-to-clipboard.js[copy2clipboard.js] to support copying links to requirement anchors for easy cross-referencing. + +== Document generation + +The AsciiDoc source is automatically processed using GitHub actions. + +For testing, documents can be generated using Windows command line tools (probably for other operating systems too, but I don't know about that; please expand on this when you figure out how). To create output from the AsciiDoc source open a command prompt in the `.ci\asciidoc-converter` folder and use: + +* `build_document.bat` to create an html document (`sdpi-supplement.html`), +* `build_document_pdf.bat` to create an Acrobat PDF document (`sdpi-supplement.pdf`). + +The output files are written to `sdpi-supplement` folder. Extracts (requirements and use-cases in JSON format) are written to the `sdpi-supplement\referenced-artifacts` folder. Extracts are generated along with the document output. + +IMPORTANT: Don't commit files in the `sdpi-supplement` folder to the repository. + == Topics Parking Lot Include: diff --git a/asciidoc/css/copy2clipboard.css b/asciidoc/css/copy2clipboard.css new file mode 100644 index 00000000..1ff100fe --- /dev/null +++ b/asciidoc/css/copy2clipboard.css @@ -0,0 +1,88 @@ +/*! derived from https://gitlab.com/antora/antora-ui-default/-/blob/master/src/css/doc.css | License: MPL-2.0 */ +.requirement .content { + position: relative; +} + +.source-toolbox { + display: flex; + position: absolute; + visibility: hidden; + top: 0.25rem; + right: 0.5rem; + color: #808080; + white-space: nowrap; + font-size: 0.85em; +} + +.content:hover .source-toolbox { + visibility: visible; +} + +.source-toolbox .source-lang { + font-family: "Droid Sans Mono", "DejaVu Sans Mono", monospace; + text-transform: uppercase; + letter-spacing: 0.075em; +} + +.source-toolbox > :not(:last-child)::after { + content: "|"; + letter-spacing: 0; + padding: 0 1ch; +} + +.source-toolbox .copy-button { + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + background: none; + border: none; + color: inherit; + outline: none; + padding: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + width: 1em; + height: 1em; +} + +.source-toolbox .copy-icon { + flex: none; + width: inherit; + height: inherit; + filter: invert(50.2%); + margin-top: 0.05em; +} + +.source-toolbox .copy-toast { + flex: none; + position: relative; + display: inline-flex; + justify-content: center; + margin-top: 1em; + border-radius: 0.25em; + padding: 0.5em; + cursor: auto; + opacity: 0; + transition: opacity 0.5s ease 0.75s; + background: rgba(0, 0, 0, 0.8); + color: #fff; +} + +.source-toolbox .copy-toast::after { + content: ""; + position: absolute; + top: 0; + width: 1em; + height: 1em; + border: 0.55em solid transparent; + border-left-color: rgba(0, 0, 0, 0.8); + transform: rotate(-90deg) translateX(50%) translateY(50%); + transform-origin: left; +} + +.source-toolbox .copy-button.clicked .copy-toast { + opacity: 1; + transition: none; +} diff --git a/asciidoc/docinfo.html b/asciidoc/docinfo.html index 0c3b3101..e86b38f9 100644 --- a/asciidoc/docinfo.html +++ b/asciidoc/docinfo.html @@ -1,4 +1,5 @@ + @@ -143,8 +144,17 @@ background:url("images/loading.png") no-repeat center center rgba(0,0,0,0.6) } +/* Styling for requirement blocks. */ +span.global-id code { + font-size: 0.6em; +} +/* Styling for requirement summary tables */ +div.global-id p { + font-family: "Droid Sans Mono","DejaVu Sans Mono",monospace +} +