Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions scripts/validate-metamodel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion skills/risk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions templates/risk.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ Because <cause>, <event> may occur, leading to <impact>.

=== 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
Expand All @@ -42,6 +47,8 @@ Because <cause>, <event> may occur, leading to <impact>.
| Confidence | Low, medium, or high.
|===

Why the rating is what it is, in as many sentences as it needs.

=== Mitigation Options

* Mitigation option.
Expand Down
35 changes: 35 additions & 0 deletions test/validate_metamodel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading