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
10 changes: 10 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1267,3 +1267,13 @@ Association rules govern the `associations` array in the process DSL, which conn
"hint": "Verify the source element ID is correct and exists in the process"
}
```

### Authored numeric risk degrees

RISK `likelihood`, `impact`, and `residual` accept the existing `low`, `medium`,
and `high` values or finite numbers. Numeric degrees require a `risk_scale`
mapping with a nonempty `id`, finite inclusive `min`/`max` bounds (`min < max`),
and `direction: higher` or `lower` indicating which direction means more risk.
The adopter defines this ordinal scale; fractions and zero are allowed within
its bounds. The validator reports invalid degrees/scales as `RISK-002` and
preserves authored values. It does not calculate risk or map numbers to words.
4 changes: 4 additions & 0 deletions extension/test-e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ export interface ReportDom {
let probeSerial = 0;
export async function reportDom(panel: vscode.WebviewPanel, op = 'read', selector?: string, x?: number): Promise<ReportDom> {
panel.reveal(undefined, true);
// A freshly replaced document may not have installed its message listener.
// Establish readiness with the retryable read before sending a one-shot action.
// Never retry clicks: a delivered action may have already changed the report.
if (op !== 'read') await reportDom(panel);
const id = ++probeSerial;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { subscription.dispose(); reject(new Error('Report DOM probe timed out')); }, 15000);
Expand Down
23 changes: 23 additions & 0 deletions packages/diagrams/src/risk/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,26 @@ describe('validateRisk — RISK-COVERAGE-001 (untreated risk, warning)', () => {
expect(warnCodes(valid())).not.toContain('RISK-COVERAGE-001');
});
});


describe('numeric authored risk degrees', () => {
const risk_scale = { id: 'adopter-v1', min: 0, max: 100, direction: 'higher' };
it('accepts bounded numbers including zero and fractions without changing input', () => {
const input = { ...valid(), likelihood: 0, impact: 99.5, residual: 25, risk_scale };
const before = JSON.stringify(input);
expect(codes(input)).not.toContain('RISK-002');
expect(JSON.stringify(input)).toBe(before);
});
it('accepts reverse scales and mixed qualitative degrees', () => {
expect(codes({ ...valid(), impact: 2, risk_scale: { ...risk_scale, min: 1, max: 5, direction: 'lower' } })).not.toContain('RISK-002');
});
it.each([-1, 101, Infinity, NaN, true, '42'])('rejects invalid degree %s', likelihood => {
expect(codes({ ...valid(), likelihood, risk_scale })).toContain('RISK-002');
});
it.each([undefined, null, [], {}, { ...risk_scale, id: '' }, { ...risk_scale, min: 100 }, { ...risk_scale, max: Infinity }, { ...risk_scale, direction: 'unknown' }])('rejects missing or malformed scale %#', scale => {
expect(codes({ ...valid(), likelihood: 3, risk_scale: scale })).toContain('RISK-002');
});
it('checks supplied scales even for qualitative degrees', () => {
expect(codes({ ...valid(), risk_scale: {} })).toContain('RISK-002');
});
});
2 changes: 1 addition & 1 deletion packages/diagrams/src/risk/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { Risk, RiskLevel } from './types.js';
export type { Risk, RiskLevel, RiskScale } from './types.js';
export { RISK_LEVELS } from './types.js';
export { validateRisk } from './validate.js';
export type { RiskValidateOptions } from './validate.js';
10 changes: 9 additions & 1 deletion packages/diagrams/src/risk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
import type { GateChecks } from '../requirement/types.js';

/** Likelihood / impact / residual severity vocabulary (§7.26). */
export type RiskLevel = 'low' | 'medium' | 'high';
export type RiskLevel = 'low' | 'medium' | 'high' | number;

export interface RiskScale {
id: string;
min: number;
max: number;
direction: 'higher' | 'lower';
}

export const RISK_LEVELS: readonly RiskLevel[] = ['low', 'medium', 'high'];

export interface Risk {
notation: 'risk';
id: string;
name: string;
risk_scale?: RiskScale;
likelihood: RiskLevel;
impact: RiskLevel;
residual: RiskLevel;
Expand Down
24 changes: 19 additions & 5 deletions packages/diagrams/src/risk/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Codes:
// RISK-001 — shape / id grammar / required envelope + per-type fields
// (likelihood, impact, residual, owner_role, threatens).
// RISK-002 — likelihood / impact / residual outside {low, medium, high}.
// RISK-002 — invalid qualitative/numeric degree or adopter scale.
// RISK-003 — threatens is empty, or an entry does not resolve to an
// admitted element in canon (no TYPE restriction — any core
// element may be threatened).
Expand Down Expand Up @@ -62,13 +62,27 @@ export function validateRisk(input: unknown, options: RiskValidateOptions = {}):
errors.push({ code: 'RISK-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
}

// likelihood / impact / residual — required (RISK-001) + enum (RISK-002).
for (const f of ['likelihood', 'impact', 'residual'] as const) {
// Authored numeric degrees use one explicitly declared adopter scale.
const fields = ['likelihood', 'impact', 'residual'] as const;
const scale = r.risk_scale as Record<string, unknown> | undefined;
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const validScale = scale !== null && typeof scale === 'object' && !Array.isArray(scale)
&& typeof scale.id === 'string' && scale.id.trim().length > 0
&& finite(scale.min) && finite(scale.max) && scale.min < scale.max
&& (scale.direction === 'higher' || scale.direction === 'lower');
if ((scale !== undefined || fields.some(f => typeof r[f] === 'number')) && !validScale) {
errors.push({ code: 'RISK-002', message: 'risk_scale requires id, finite min < max, and direction higher or lower.', path: 'risk_scale' });
}
for (const f of fields) {
const v = r[f];
if (v === undefined || v === null || v === '') {
errors.push({ code: 'RISK-001', message: `${f} is required.`, path: f });
} else if (!(RISK_LEVELS as readonly string[]).includes(v as string)) {
errors.push({ code: 'RISK-002', message: `${f} "${String(v)}" must be one of ${RISK_LEVELS.join(', ')}.`, path: f });
} else if (typeof v === 'number') {
if (!finite(v) || (validScale && (v < (scale!.min as number) || v > (scale!.max as number)))) {
errors.push({ code: 'RISK-002', message: `${f} must be finite and within risk_scale bounds.`, path: f });
}
} else if (!(RISK_LEVELS as readonly unknown[]).includes(v)) {
errors.push({ code: 'RISK-002', message: `${f} must be low, medium, high, or a number with risk_scale.`, path: f });
}
}

Expand Down
Loading