diff --git a/scripts/validate-metamodel.rb b/scripts/validate-metamodel.rb index c497aff..c236f7f 100755 --- a/scripts/validate-metamodel.rb +++ b/scripts/validate-metamodel.rb @@ -496,17 +496,22 @@ class ArtifactIndexGenerator output: '11-risks-and-technical-debt/generated/doc-11001-risks.adoc', anchor: 'risks', title: 'Risks', - cols: '1,3,1,1,1,3', - columns: ['ID', 'Risk', 'Probability', 'Impact', 'Priority', 'Mitigation/action'], + cols: '1,3,1,1,1,2,2', + columns: ['ID', 'Risk', 'Probability', 'Impact', 'Priority', 'Affects', 'Mitigation/action'], row: lambda do |artifact, helper| fields = helper.definition_table_fields(artifact) [ helper.artifact_link(artifact, label: helper.short_id(artifact.metadata['id'])), helper.cell(artifact.metadata['title']), - helper.cell(fields['Likelihood']), - helper.cell(fields['Impact']), - helper.cell(fields['Priority']), - helper.relation_targets(artifact, 'affects') + # The assessment fields are prose in the source artifact; only their + # verdict fits a register column. See #summary_cell. + helper.summary_cell(fields['Likelihood']), + helper.summary_cell(fields['Impact']), + helper.summary_cell(fields['Priority']), + # What the risk endangers, and what is being done about it. These are + # opposites and had shared one column headed "Mitigation/action". + helper.relation_targets(artifact, 'affects'), + helper.incoming_relation_sources(artifact, 'mitigates') ] end } @@ -1030,6 +1035,27 @@ def relation_targets(artifact, type) matches.map { |relation| artifact_ref(relation['target']) }.join(" +\n") end + # The mirror of #relation_targets: who points *at* this artifact with the given + # relation type. Relations are only ever authored outgoing, so a question like + # "what mitigates this risk?" can only be answered from the other side. + def incoming_relation_sources(artifact, type) + id = artifact.metadata['id'] + return '-' unless id + + sources = @artifacts_by_id.values.select do |candidate| + next false unless candidate.metadata + + Array(candidate.metadata['relations']).any? do |relation| + relation['type'] == type && relation['target'] == id + end + end + return '-' if sources.empty? + + sources.sort_by { |source| source.metadata['id'].to_s } + .map { |source| artifact_link(source) } + .join(" +\n") + end + def derived_from_cell(entries) origins = Array(entries).compact return '-' if origins.empty? @@ -1057,6 +1083,21 @@ def cell(value) text.gsub('|', '\|').gsub("\n", ' ') end + # For narrow index columns fed from prose fields. An assessment reads + # "Medium. It takes one lapse of attention while transcribing." — the verdict + # belongs in the register, the reasoning belongs in the artifact. Without this, + # writing a normal sentence in the source silently wrecks the generated table, + # and the author only finds out by rendering it. + # + # Only a sentence boundary counts: "Medium (rises when hosted)" survives whole. + def summary_cell(value) + text = value.to_s.strip + return '-' if text.empty? + + verdict = text[/\A(.+?)\.(?:\s|\z)/, 1] + cell(verdict && !verdict.empty? ? verdict : text) + end + private def derived_from_entry(entry) diff --git a/skills/risk/SKILL.md b/skills/risk/SKILL.md index ae2db4e..9d775b5 100644 --- a/skills/risk/SKILL.md +++ b/skills/risk/SKILL.md @@ -132,7 +132,9 @@ Use these templates: - The impact is tied to architecture outcomes, quality scenarios, delivery, operations, security, compliance, or stakeholder value. - Likelihood, impact, priority, timeframe, and confidence are qualitative and - reviewable. + reviewable, and each leads with its verdict. The generated risk register + carries only the first sentence of likelihood, impact and priority; the + reasoning belongs in a paragraph below the assessment table. - Mitigations, monitoring, or review actions are realistic and owned. - Assumptions and open questions are explicit. - Risk acceptance remains a human decision. diff --git a/templates/risk.adoc b/templates/risk.adoc index 268b2c5..a19e95a 100644 --- a/templates/risk.adoc +++ b/templates/risk.adoc @@ -32,6 +32,11 @@ Because , may occur, leading to . === Assessment +Likelihood, impact and priority are lifted into the generated risk register, +which has one narrow column each. Only the first sentence is carried over, so +lead with the verdict and put the reasoning in a paragraph below the table — +`Medium. It only takes one lapse of attention.` reaches the register as `Medium`. + [cols="1,3", options="header"] |=== | Field | Value @@ -42,6 +47,8 @@ Because , may occur, leading to . | Confidence | Low, medium, or high. |=== +Why the rating is what it is, in as many sentences as it needs. + === Mitigation Options * Mitigation option. diff --git a/test/validate_metamodel_test.rb b/test/validate_metamodel_test.rb index d259bce..9262009 100644 --- a/test/validate_metamodel_test.rb +++ b/test/validate_metamodel_test.rb @@ -572,6 +572,41 @@ def test_artifact_index_output_is_deterministic assert_includes first, '| xref:q-arch-001[Should the valid fixture demonstrate ADR provenance?]' end + def test_risk_index_separates_what_a_risk_affects_from_what_mitigates_it + # Given: the toolkit's own risks, which R-001 affects a quality scenario and + # ADR-007 mitigates + validator = MetamodelValidator.new( + root: ROOT, + docs_dir: ROOT.join('src/docs'), + relations_schema: SCHEMA + ) + artifacts = validator.validate + generator = ArtifactIndexGenerator.new(root: ROOT, docs_dir: ROOT.join('src/docs')) + definition = ArtifactIndexGenerator::INDEX_DEFINITIONS.fetch('Risk') + output_path = ROOT.join('tmp/test-doc-11001-risks.adoc') + + # When: the risk register is rendered + rendered = generator.render(artifacts, definition, output_path) + + # Then: the two are separate columns, and the mitigation column is fed from + # the incoming mitigates relation rather than from what the risk endangers + assert_includes rendered, '| ID | Risk | Probability | Impact | Priority | Affects | Mitigation/action' + assert_includes rendered, 'xref:qs-004-ai-suggestion-reviewability[QS-004-ai-suggestion-reviewability]' + assert_includes rendered, 'xref:adr-007-artifact-provenance-metadata[ADR-007-artifact-provenance-metadata]' + end + + def test_risk_index_reduces_a_prose_assessment_to_its_verdict + # Given: an assessment field written as a normal sentence, and one written + # with a parenthetical that is not a sentence boundary + helper = ArtifactRenderHelper.new(ROOT.join('tmp/test-summary-cell.adoc'), []) + + # When/Then: only a sentence boundary truncates; everything else survives + assert_equal 'Medium', helper.summary_cell('Medium. It takes one lapse of attention') + assert_equal 'Medium (rises when hosted)', helper.summary_cell('Medium (rises when hosted)') + assert_equal 'Low, medium, or high', helper.summary_cell('Low, medium, or high') + assert_equal '-', helper.summary_cell('') + end + def test_open_questions_index_output_is_deterministic # Given: the arc42 source tree with open questions generator = OpenQuestionsIndexGenerator.new(