@juangcarmona/lighthouse-sdlc 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/commands/triage.md +6 -0
- package/contract.json +2 -1
- package/package.json +4 -2
- package/schemas/sdlc-contract.schema.json +3 -2
- package/schemas/triage.schema.json +62 -0
- package/skills/triage/SKILL.md +6 -5
- package/src/sdlc.mjs +64 -6
- package/src/triage.mjs +284 -0
package/README.md
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
This package provides the provider-neutral delivery contract for Lighthouse adopters.
|
|
4
4
|
|
|
5
|
-
It owns the six lifecycle skills, their command entrypoints, route and gate descriptors, control-plane schema and deterministic package check.
|
|
5
|
+
It owns the six lifecycle skills, their command entrypoints, route and gate descriptors, the triage schema and verifier, the control-plane schema and deterministic package check.
|
|
6
6
|
|
|
7
7
|
It does not run agents, store transcripts, dispatch work between repositories or replace ProductShape, ArcShape, OpenSpec or an agent host.
|
|
8
8
|
|
|
9
9
|
Install it with the Lighthouse CLI or run `lighthouse-sdlc verify` from the package installation to check the distributed contract.
|
|
10
10
|
|
|
11
|
+
Run `lighthouse-sdlc triage --input <file>` to validate one structured triage result and receive its proposed workflow and classification labels. The command is read-only by default; `--apply --labels-file <file>` writes only after the report is ready and the labels file still matches the input.
|
|
12
|
+
|
|
13
|
+
Evidence is explicit: an absent field appears in `missingEvidence`, a check that cannot run appears in `unavailableChecks`, and a judgement awaiting review appears in `pendingReview`. Only `provided` evidence can make a report ready to apply.
|
|
14
|
+
|
|
11
15
|
The package is a source of lifecycle instructions and contracts; an adopter's host adapter decides where provider-specific projections are installed.
|
package/commands/triage.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
# `/lighthouse:triage`
|
|
2
2
|
|
|
3
3
|
Run the `lighthouse-sdlc-triage` skill for one work item and stop at the `triaged` gate.
|
|
4
|
+
|
|
5
|
+
The structured result records `size`, `urgency`, `impact`, `risk`, `blast-radius`, dependencies, route, owner, confidence and evidence, then proposes workflow and classification labels from the project's configuration.
|
|
6
|
+
|
|
7
|
+
Use `lighthouse-sdlc triage --input <file>` for deterministic validation. The command is read-only unless an explicit `--apply --labels-file <file>` request is supplied.
|
|
8
|
+
|
|
9
|
+
Represent evidence with `status: provided`, `status: unavailable` or `status: pending-review`; omit the item when evidence is absent. The report keeps those cases in `missingEvidence`, `unavailableChecks` and `pendingReview` respectively.
|
package/contract.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juangcarmona/lighthouse-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
|
|
6
6
|
"repository": {
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
},
|
|
36
36
|
"exports": {
|
|
37
37
|
".": "./src/sdlc.mjs",
|
|
38
|
+
"./triage": "./src/triage.mjs",
|
|
38
39
|
"./contract.json": "./contract.json",
|
|
39
|
-
"./schemas/control-plane.schema.json": "./schemas/control-plane.schema.json"
|
|
40
|
+
"./schemas/control-plane.schema.json": "./schemas/control-plane.schema.json",
|
|
41
|
+
"./schemas/triage.schema.json": "./schemas/triage.schema.json"
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
"assets": {
|
|
18
18
|
"type": "object",
|
|
19
19
|
"additionalProperties": false,
|
|
20
|
-
"required": ["routes", "gates", "controlPlaneSchema"],
|
|
20
|
+
"required": ["routes", "gates", "controlPlaneSchema", "triageSchema"],
|
|
21
21
|
"properties": {
|
|
22
22
|
"routes": { "type": "string" },
|
|
23
23
|
"gates": { "type": "string" },
|
|
24
|
-
"controlPlaneSchema": { "type": "string" }
|
|
24
|
+
"controlPlaneSchema": { "type": "string" },
|
|
25
|
+
"triageSchema": { "type": "string" }
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/juangcarmona/lighthouse/schemas/lighthouse-sdlc-triage.schema.json",
|
|
4
|
+
"title": "Lighthouse triage input",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "workItem", "currentLabels", "assessment", "configuration"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "const": 1 },
|
|
10
|
+
"workItem": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"required": ["id", "title"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": { "type": "string", "minLength": 1 },
|
|
16
|
+
"title": { "type": "string", "minLength": 1 },
|
|
17
|
+
"type": { "type": "string", "minLength": 1 }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"currentLabels": { "type": "array", "items": { "type": "string" } },
|
|
21
|
+
"assessment": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"required": ["size", "urgency", "impact", "risk", "blastRadius", "dependencies", "route", "nextState", "owner", "confidence", "evidence"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"size": { "enum": ["S", "M", "L", "XL"] },
|
|
27
|
+
"urgency": { "enum": ["normal", "asap", "now"] },
|
|
28
|
+
"impact": { "enum": ["low", "medium", "high"] },
|
|
29
|
+
"risk": { "enum": ["low", "medium", "high"] },
|
|
30
|
+
"blastRadius": { "enum": ["low", "medium", "high", "unknown"] },
|
|
31
|
+
"dependencies": { "type": "array" },
|
|
32
|
+
"route": { "enum": ["supervised", "plan-only", "unattended", "hotfix", "stacked"] },
|
|
33
|
+
"nextState": { "type": "string", "minLength": 1 },
|
|
34
|
+
"owner": { "type": "string", "minLength": 1 },
|
|
35
|
+
"confidence": { "type": "object" },
|
|
36
|
+
"evidence": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": false,
|
|
41
|
+
"required": ["field"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"field": { "enum": ["size", "urgency", "impact", "risk", "blast-radius"] },
|
|
44
|
+
"status": { "enum": ["provided", "unavailable", "pending-review"] },
|
|
45
|
+
"source": { "type": "string", "minLength": 1 },
|
|
46
|
+
"reason": { "type": "string", "minLength": 1 }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"configuration": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"required": ["labels", "transitions"],
|
|
56
|
+
"properties": {
|
|
57
|
+
"labels": { "type": "array" },
|
|
58
|
+
"transitions": { "type": "array" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/skills/triage/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lighthouse-sdlc-triage
|
|
3
|
-
description: Use when a work item enters the Lighthouse lifecycle and needs urgency, impact, dependencies, risk,
|
|
3
|
+
description: Use when a work item enters the Lighthouse lifecycle and needs size, urgency, impact, dependencies, risk, blast radius and a delivery route.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Triage
|
|
@@ -11,10 +11,11 @@ Classify one work item before refinement.
|
|
|
11
11
|
|
|
12
12
|
1. Read the work item, its linked Product Change and the repository control plane.
|
|
13
13
|
2. Inspect the affected code and architecture evidence with the available repository tools.
|
|
14
|
-
3. Record urgency, impact, dependencies, risk,
|
|
15
|
-
4.
|
|
16
|
-
5.
|
|
14
|
+
3. Record size, urgency, impact, dependencies, risk, blast radius, route and owner with confidence and evidence for each assessment.
|
|
15
|
+
4. Mark each evidence field as provided, unavailable or pending-review; leave absent fields without an evidence item.
|
|
16
|
+
5. Propose only the project's declared workflow and classification labels and report the transition.
|
|
17
|
+
6. Return the structured triage record and stop at the `triaged` gate.
|
|
17
18
|
|
|
18
19
|
## Completion
|
|
19
20
|
|
|
20
|
-
The record names the route, owner, labels,
|
|
21
|
+
The record names the route, owner, labels, assessments, confidence, evidence and next decision, and no later stage has started.
|
package/src/sdlc.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { dirname, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { applyTriageLabels, buildTriageReport } from './triage.mjs';
|
|
6
7
|
|
|
7
8
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
const contractPath = resolve(packageRoot, 'contract.json');
|
|
@@ -28,10 +29,10 @@ async function verifyContract() {
|
|
|
28
29
|
for (const stage of stages) {
|
|
29
30
|
for (const asset of [stage.skill, stage.command]) if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing stage asset ${asset}`);
|
|
30
31
|
}
|
|
31
|
-
for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.controlPlaneSchema]) {
|
|
32
|
+
for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema]) {
|
|
32
33
|
if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
|
|
33
34
|
}
|
|
34
|
-
for (const asset of ['routes.json', 'gates.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/control-plane.schema.json']) {
|
|
35
|
+
for (const asset of ['routes.json', 'gates.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'src/triage.mjs']) {
|
|
35
36
|
if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
|
|
36
37
|
}
|
|
37
38
|
try {
|
|
@@ -49,13 +50,70 @@ async function verifyContract() {
|
|
|
49
50
|
return problems;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function option(args, name) {
|
|
54
|
+
const index = args.indexOf(name);
|
|
55
|
+
if (index !== -1) return args[index + 1];
|
|
56
|
+
const prefix = `${name}=`;
|
|
57
|
+
return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function triageText(report) {
|
|
61
|
+
const lines = [
|
|
62
|
+
`Triage: ${report.status}`,
|
|
63
|
+
`Work item: ${report.workItem?.id ?? '(unknown)'} — ${report.workItem?.title ?? '(untitled)'}`,
|
|
64
|
+
`Size: ${report.assessment.size}`,
|
|
65
|
+
`Urgency: ${report.assessment.urgency}`,
|
|
66
|
+
`Impact: ${report.assessment.impact}`,
|
|
67
|
+
`Risk: ${report.assessment.risk}`,
|
|
68
|
+
`Blast radius: ${report.assessment.blastRadius}`,
|
|
69
|
+
`Route: ${report.assessment.route ?? '(unknown)'}`,
|
|
70
|
+
`Transition: ${report.transition.from ?? '(unknown)'} -> ${report.transition.to ?? '(unknown)'}`,
|
|
71
|
+
`Labels: ${report.labels.proposed.join(', ') || '(none)'}`,
|
|
72
|
+
];
|
|
73
|
+
if (report.missingEvidence.length) lines.push(`Missing evidence: ${report.missingEvidence.join(', ')}`);
|
|
74
|
+
if (report.unavailableChecks.length) lines.push(`Unavailable checks: ${report.unavailableChecks.join(', ')}`);
|
|
75
|
+
if (report.pendingReview.length) lines.push(`Pending review: ${report.pendingReview.join(', ')}`);
|
|
76
|
+
for (const error of report.errors) lines.push(`Error: ${error.message}`);
|
|
77
|
+
lines.push(report.applied ? 'Applied: yes' : 'Applied: no (read-only)');
|
|
78
|
+
return `${lines.join('\n')}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runTriage(args) {
|
|
82
|
+
const inputPath = option(args, '--input');
|
|
83
|
+
const configPath = option(args, '--config');
|
|
84
|
+
const labelsPath = option(args, '--labels-file');
|
|
85
|
+
const format = option(args, '--format') ?? 'text';
|
|
86
|
+
const apply = args.includes('--apply');
|
|
87
|
+
if (!inputPath || !['text', 'json'].includes(format) || (apply && !labelsPath)) {
|
|
88
|
+
console.error('Usage: lighthouse-sdlc triage --input <file> [--config <file>] [--format <text|json>] [--apply --labels-file <file>]');
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
const input = await readJson(resolve(process.cwd(), inputPath));
|
|
92
|
+
if (configPath) input.configuration = await readJson(resolve(process.cwd(), configPath));
|
|
93
|
+
const report = buildTriageReport(input);
|
|
94
|
+
report.applied = false;
|
|
95
|
+
if (apply && report.canApply) {
|
|
96
|
+
const labelsFile = resolve(process.cwd(), labelsPath);
|
|
97
|
+
const current = await readJson(labelsFile);
|
|
98
|
+
const expected = JSON.stringify(report.labels.current);
|
|
99
|
+
if (!Array.isArray(current) || JSON.stringify([...new Set(current)].sort()) !== expected) throw new Error('labels file does not match currentLabels; refusing to apply the triage projection');
|
|
100
|
+
await writeFile(labelsFile, `${JSON.stringify(applyTriageLabels(report), null, 2)}\n`);
|
|
101
|
+
report.applied = true;
|
|
102
|
+
}
|
|
103
|
+
if (format === 'json') console.log(JSON.stringify(report, null, 2));
|
|
104
|
+
else process.stdout.write(triageText(report));
|
|
105
|
+
if (report.status !== 'ready') process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
52
108
|
const command = process.argv[2] ?? 'verify';
|
|
53
|
-
if (!['contract', 'verify'].includes(command)) {
|
|
54
|
-
console.error('Usage: lighthouse-sdlc <contract|verify>');
|
|
109
|
+
if (!['contract', 'verify', 'triage'].includes(command)) {
|
|
110
|
+
console.error('Usage: lighthouse-sdlc <contract|verify|triage>');
|
|
55
111
|
process.exit(2);
|
|
56
112
|
}
|
|
57
113
|
if (command === 'contract') {
|
|
58
114
|
console.log(await readFile(contractPath, 'utf8'));
|
|
115
|
+
} else if (command === 'triage') {
|
|
116
|
+
await runTriage(process.argv.slice(3));
|
|
59
117
|
} else {
|
|
60
118
|
const problems = await verifyContract();
|
|
61
119
|
if (problems.length) {
|
|
@@ -65,4 +123,4 @@ if (command === 'contract') {
|
|
|
65
123
|
console.log('Lighthouse SDLC package contract is valid.');
|
|
66
124
|
}
|
|
67
125
|
|
|
68
|
-
export { contractPath, verifyContract };
|
|
126
|
+
export { contractPath, runTriage, verifyContract };
|
package/src/triage.mjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const DIMENSIONS = Object.freeze({
|
|
2
|
+
size: ['S', 'M', 'L', 'XL'],
|
|
3
|
+
urgency: ['normal', 'asap', 'now'],
|
|
4
|
+
impact: ['low', 'medium', 'high'],
|
|
5
|
+
risk: ['low', 'medium', 'high'],
|
|
6
|
+
'blast-radius': ['low', 'medium', 'high'],
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const CONFIDENCE = new Set(['low', 'medium', 'high']);
|
|
10
|
+
const ROLES = new Set(['state', 'command', 'classification']);
|
|
11
|
+
const ROUTES = new Set(['supervised', 'plan-only', 'unattended', 'hotfix', 'stacked']);
|
|
12
|
+
const DEPENDENCY_STATES = new Set(['resolved', 'open', 'unknown']);
|
|
13
|
+
const EVIDENCE_STATES = new Set(['provided', 'unavailable', 'pending-review']);
|
|
14
|
+
const HEX = /^[0-9a-f]{6}$/iu;
|
|
15
|
+
const EVIDENCE_FIELDS = ['size', 'urgency', 'impact', 'risk', 'blast-radius'];
|
|
16
|
+
|
|
17
|
+
export const suggestedLabelColors = Object.freeze({
|
|
18
|
+
'size-s': 'BFE3FF',
|
|
19
|
+
'size-m': '80C2FF',
|
|
20
|
+
'size-l': '4DA3FF',
|
|
21
|
+
'size-xl': '1F6FEB',
|
|
22
|
+
'urgency-normal': '8B949E',
|
|
23
|
+
'urgency-asap': 'D29922',
|
|
24
|
+
'urgency-now': 'CF222E',
|
|
25
|
+
'impact-low': 'E9D5FF',
|
|
26
|
+
'impact-medium': 'A371F7',
|
|
27
|
+
'impact-high': '6E40C9',
|
|
28
|
+
'risk-low': 'FFEBE9',
|
|
29
|
+
'risk-medium': 'F0883E',
|
|
30
|
+
'risk-high': 'CF222E',
|
|
31
|
+
'blast-radius-low': 'C7F0E8',
|
|
32
|
+
'blast-radius-medium': '4FB3A4',
|
|
33
|
+
'blast-radius-high': '0F766E',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizedValue(dimension, value) {
|
|
41
|
+
if (dimension === 'size') return String(value ?? '').toUpperCase();
|
|
42
|
+
return String(value ?? '').toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function labelFor(dimension, value) {
|
|
46
|
+
return `${dimension}-${normalizedValue(dimension, value).toLowerCase()}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function issue(code, message, field) {
|
|
50
|
+
return field ? { code, field, message } : { code, message };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addIssue(issues, code, message, field) {
|
|
54
|
+
issues.push(issue(code, message, field));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateConfiguration(configuration) {
|
|
58
|
+
const errors = [];
|
|
59
|
+
const labels = Array.isArray(configuration?.labels) ? configuration.labels : null;
|
|
60
|
+
const byName = new Map();
|
|
61
|
+
const classifications = new Map();
|
|
62
|
+
const stateLabels = new Set();
|
|
63
|
+
const commandLabels = new Set();
|
|
64
|
+
|
|
65
|
+
if (!labels) addIssue(errors, 'configuration.labels', 'configuration.labels must be an array', 'configuration.labels');
|
|
66
|
+
for (const descriptor of labels ?? []) {
|
|
67
|
+
if (!isRecord(descriptor) || typeof descriptor.name !== 'string' || !descriptor.name.trim()) {
|
|
68
|
+
addIssue(errors, 'label.invalid', 'every label descriptor needs a non-empty name', 'configuration.labels');
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const name = descriptor.name.trim();
|
|
72
|
+
if (byName.has(name)) addIssue(errors, 'label.duplicate', `label ${name} is declared more than once`, 'configuration.labels');
|
|
73
|
+
byName.set(name, descriptor);
|
|
74
|
+
if (!ROLES.has(descriptor.role)) {
|
|
75
|
+
addIssue(errors, 'label.role', `label ${name} must use role state, command or classification`, 'configuration.labels');
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (descriptor.role === 'state') stateLabels.add(name);
|
|
79
|
+
if (descriptor.role === 'command') commandLabels.add(name);
|
|
80
|
+
if (descriptor.role !== 'classification') continue;
|
|
81
|
+
const dimension = String(descriptor.dimension ?? '').toLowerCase();
|
|
82
|
+
const value = normalizedValue(dimension, descriptor.value);
|
|
83
|
+
if (!Object.hasOwn(DIMENSIONS, dimension) || !DIMENSIONS[dimension].includes(value)) {
|
|
84
|
+
addIssue(errors, 'label.classification', `label ${name} has an invalid classification dimension or value`, 'configuration.labels');
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (labelFor(dimension, value) !== name) {
|
|
88
|
+
addIssue(errors, 'label.syntax', `classification label ${name} must use the ${labelFor(dimension, value)} syntax`, 'configuration.labels');
|
|
89
|
+
}
|
|
90
|
+
const key = `${dimension}:${value}`;
|
|
91
|
+
if (classifications.has(key)) addIssue(errors, 'label.classification-duplicate', `classification ${key} is declared more than once`, 'configuration.labels');
|
|
92
|
+
classifications.set(key, name);
|
|
93
|
+
if (descriptor.color !== undefined && (typeof descriptor.color !== 'string' || !HEX.test(descriptor.color))) {
|
|
94
|
+
addIssue(errors, 'label.color', `classification label ${name} color must be six hexadecimal characters`, 'configuration.labels');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const transitions = Array.isArray(configuration?.transitions) ? configuration.transitions : [];
|
|
99
|
+
const transitionSet = new Set();
|
|
100
|
+
for (const transition of transitions) {
|
|
101
|
+
if (!isRecord(transition) || typeof transition.from !== 'string' || typeof transition.to !== 'string') {
|
|
102
|
+
addIssue(errors, 'transition.invalid', 'every transition needs from and to labels', 'configuration.transitions');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const key = `${transition.from}->${transition.to}`;
|
|
106
|
+
if (transitionSet.has(key)) addIssue(errors, 'transition.duplicate', `transition ${key} is declared more than once`, 'configuration.transitions');
|
|
107
|
+
transitionSet.add(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { errors, byName, classifications, stateLabels, commandLabels, transitionSet };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateWorkItem(workItem, errors) {
|
|
114
|
+
if (!isRecord(workItem)) {
|
|
115
|
+
addIssue(errors, 'work-item.invalid', 'workItem must be an object', 'workItem');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const field of ['id', 'title']) {
|
|
119
|
+
if (typeof workItem[field] !== 'string' || !workItem[field].trim()) addIssue(errors, `work-item.${field}`, `workItem.${field} must be a non-empty string`, `workItem.${field}`);
|
|
120
|
+
}
|
|
121
|
+
if (workItem.type !== undefined && (typeof workItem.type !== 'string' || !workItem.type.trim())) addIssue(errors, 'work-item.type', 'workItem.type must be a non-empty string when provided', 'workItem.type');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateAssessment(assessment, errors) {
|
|
125
|
+
if (!isRecord(assessment)) {
|
|
126
|
+
addIssue(errors, 'assessment.invalid', 'assessment must be an object', 'assessment');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const dimension of Object.keys(DIMENSIONS)) {
|
|
130
|
+
const value = normalizedValue(dimension, assessment[dimension === 'blast-radius' ? 'blastRadius' : dimension]);
|
|
131
|
+
if (!DIMENSIONS[dimension].includes(value) && !(dimension === 'blast-radius' && value === 'unknown')) {
|
|
132
|
+
addIssue(errors, `assessment.${dimension}`, `${dimension} has an unsupported value`, `assessment.${dimension}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!ROUTES.has(assessment.route)) addIssue(errors, 'assessment.route', `route must be one of ${[...ROUTES].join(', ')}`, 'assessment.route');
|
|
136
|
+
if (typeof assessment.nextState !== 'string' || !assessment.nextState.trim()) addIssue(errors, 'assessment.next-state', 'nextState must be a non-empty string', 'assessment.nextState');
|
|
137
|
+
if (typeof assessment.owner !== 'string' || !assessment.owner.trim()) addIssue(errors, 'assessment.owner', 'owner must be a non-empty string', 'assessment.owner');
|
|
138
|
+
if (!isRecord(assessment.confidence)) {
|
|
139
|
+
addIssue(errors, 'assessment.confidence', 'confidence must be an object with one value per triage dimension', 'assessment.confidence');
|
|
140
|
+
} else {
|
|
141
|
+
for (const dimension of EVIDENCE_FIELDS) if (!CONFIDENCE.has(assessment.confidence[dimension])) addIssue(errors, `assessment.confidence.${dimension}`, `${dimension} confidence must be low, medium or high`, `assessment.confidence.${dimension}`);
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(assessment.dependencies)) {
|
|
144
|
+
addIssue(errors, 'assessment.dependencies', 'dependencies must be an array', 'assessment.dependencies');
|
|
145
|
+
} else {
|
|
146
|
+
for (const dependency of assessment.dependencies) {
|
|
147
|
+
if (!isRecord(dependency) || typeof dependency.id !== 'string' || !DEPENDENCY_STATES.has(dependency.status)) addIssue(errors, 'assessment.dependency', 'each dependency needs an id and a resolved, open or unknown status', 'assessment.dependencies');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(assessment.evidence)) {
|
|
151
|
+
addIssue(errors, 'assessment.evidence', 'evidence must be an array', 'assessment.evidence');
|
|
152
|
+
} else {
|
|
153
|
+
const fields = new Set();
|
|
154
|
+
for (const evidence of assessment.evidence) {
|
|
155
|
+
const status = evidence?.status ?? 'provided';
|
|
156
|
+
const valid = isRecord(evidence) && EVIDENCE_FIELDS.includes(evidence.field) && EVIDENCE_STATES.has(status);
|
|
157
|
+
if (!valid) {
|
|
158
|
+
addIssue(errors, 'assessment.evidence-item', 'each evidence item needs a known field and a provided, unavailable or pending-review status', 'assessment.evidence');
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (fields.has(evidence.field)) addIssue(errors, 'assessment.evidence-duplicate', `${evidence.field} has more than one evidence status`, 'assessment.evidence');
|
|
162
|
+
fields.add(evidence.field);
|
|
163
|
+
if (status === 'provided' && (typeof evidence.source !== 'string' || !evidence.source.trim())) addIssue(errors, 'assessment.evidence-source', 'provided evidence needs a non-empty source', 'assessment.evidence');
|
|
164
|
+
if (status !== 'provided' && (typeof evidence.reason !== 'string' || !evidence.reason.trim())) addIssue(errors, 'assessment.evidence-reason', `${status} evidence needs a non-empty reason`, 'assessment.evidence');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function currentState(labels, configuration) {
|
|
170
|
+
const states = labels.filter((label) => configuration.stateLabels.has(label));
|
|
171
|
+
return { states, value: states.length === 1 ? states[0] : null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function classificationState(labels, configuration) {
|
|
175
|
+
const byDimension = new Map();
|
|
176
|
+
for (const label of labels) {
|
|
177
|
+
const descriptor = configuration.byName.get(label);
|
|
178
|
+
if (!descriptor || descriptor.role !== 'classification') continue;
|
|
179
|
+
const list = byDimension.get(descriptor.dimension) ?? [];
|
|
180
|
+
list.push(label);
|
|
181
|
+
byDimension.set(descriptor.dimension, list);
|
|
182
|
+
}
|
|
183
|
+
return byDimension;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function proposedLabels(currentLabels, assessment, configuration) {
|
|
187
|
+
const result = new Set(currentLabels.filter((label) => {
|
|
188
|
+
const descriptor = configuration.byName.get(label);
|
|
189
|
+
return !descriptor || (descriptor.role !== 'state' && descriptor.role !== 'classification' && descriptor.role !== 'command');
|
|
190
|
+
}));
|
|
191
|
+
for (const label of configuration.stateLabels) if (currentLabels.includes(label)) result.add(label);
|
|
192
|
+
for (const label of configuration.commandLabels) if (currentLabels.includes(label)) result.delete(label);
|
|
193
|
+
const state = currentState(currentLabels, configuration).value;
|
|
194
|
+
if (state) result.delete(state);
|
|
195
|
+
if (assessment.nextState) result.add(assessment.nextState);
|
|
196
|
+
for (const dimension of Object.keys(DIMENSIONS)) {
|
|
197
|
+
const key = dimension === 'blast-radius' ? 'blastRadius' : dimension;
|
|
198
|
+
const value = normalizedValue(dimension, assessment[key]);
|
|
199
|
+
for (const label of configuration.classifications.values()) {
|
|
200
|
+
const descriptor = configuration.byName.get(label);
|
|
201
|
+
if (descriptor?.dimension === dimension) result.delete(label);
|
|
202
|
+
}
|
|
203
|
+
if (value !== 'UNKNOWN') {
|
|
204
|
+
const label = configuration.classifications.get(`${dimension}:${value}`);
|
|
205
|
+
if (label) result.add(label);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...result].sort((a, b) => a.localeCompare(b));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildTriageReport(input) {
|
|
212
|
+
const errors = [];
|
|
213
|
+
if (!isRecord(input)) return { schemaVersion: 1, status: 'invalid', valid: false, canApply: false, errors: [issue('input.invalid', 'triage input must be an object')], warnings: [], missingEvidence: [], unavailableChecks: [], pendingReview: [], labels: { current: [], proposed: [] } };
|
|
214
|
+
if (input.schemaVersion !== 1) addIssue(errors, 'input.schema-version', 'schemaVersion must be 1', 'schemaVersion');
|
|
215
|
+
const configuration = validateConfiguration(input.configuration);
|
|
216
|
+
errors.push(...configuration.errors);
|
|
217
|
+
const currentLabels = Array.isArray(input.currentLabels) ? [...new Set(input.currentLabels.filter((label) => typeof label === 'string'))].sort() : [];
|
|
218
|
+
if (!Array.isArray(input.currentLabels)) addIssue(errors, 'labels.invalid', 'currentLabels must be an array', 'currentLabels');
|
|
219
|
+
validateWorkItem(input.workItem, errors);
|
|
220
|
+
validateAssessment(input.assessment, errors);
|
|
221
|
+
|
|
222
|
+
const state = currentState(currentLabels, configuration);
|
|
223
|
+
if (state.states.length > 1) addIssue(errors, 'state.ambiguous', `work item has multiple workflow state labels: ${state.states.join(', ')}`, 'currentLabels');
|
|
224
|
+
if (state.states.length === 0) addIssue(errors, 'state.missing', 'work item needs one configured workflow state label', 'currentLabels');
|
|
225
|
+
const assessment = isRecord(input.assessment) ? input.assessment : {};
|
|
226
|
+
if (state.value && typeof assessment.nextState === 'string' && configuration.stateLabels.has(assessment.nextState)) {
|
|
227
|
+
if (state.value !== assessment.nextState && !configuration.transitionSet.has(`${state.value}->${assessment.nextState}`)) addIssue(errors, 'transition.illegal', `transition ${state.value}->${assessment.nextState} is not configured`, 'assessment.nextState');
|
|
228
|
+
} else if (typeof assessment.nextState === 'string' && assessment.nextState && !configuration.stateLabels.has(assessment.nextState)) {
|
|
229
|
+
addIssue(errors, 'transition.unconfigured', `next state ${assessment.nextState} is not configured`, 'assessment.nextState');
|
|
230
|
+
}
|
|
231
|
+
const currentClassifications = classificationState(currentLabels, configuration);
|
|
232
|
+
for (const [dimension, values] of currentClassifications) if (values.length > 1) addIssue(errors, 'classification.ambiguous', `work item has multiple ${dimension} classification labels: ${values.join(', ')}`, 'currentLabels');
|
|
233
|
+
for (const dimension of Object.keys(DIMENSIONS)) {
|
|
234
|
+
const value = normalizedValue(dimension, assessment[dimension === 'blast-radius' ? 'blastRadius' : dimension]);
|
|
235
|
+
if (value !== 'UNKNOWN' && configuration.classifications.has(`${dimension}:${value}`) === false) addIssue(errors, 'classification.unconfigured', `${labelFor(dimension, value)} is not configured`, `assessment.${dimension}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const evidence = Array.isArray(assessment.evidence) ? assessment.evidence : [];
|
|
239
|
+
const evidenceFields = new Set(evidence.filter(isRecord).map((item) => item.field));
|
|
240
|
+
const missingEvidence = EVIDENCE_FIELDS.filter((field) => !evidenceFields.has(field));
|
|
241
|
+
const unavailableChecks = evidence.filter((item) => isRecord(item) && item.status === 'unavailable').map((item) => item.field);
|
|
242
|
+
const pendingReview = evidence.filter((item) => isRecord(item) && item.status === 'pending-review').map((item) => item.field);
|
|
243
|
+
const warnings = [
|
|
244
|
+
...missingEvidence.map((field) => issue('evidence.missing', `${field} has no evidence record`, field)),
|
|
245
|
+
...unavailableChecks.map((field) => issue('evidence.unavailable', `${field} could not be checked`, field)),
|
|
246
|
+
...pendingReview.map((field) => issue('evidence.pending-review', `${field} is waiting for judgement`, field)),
|
|
247
|
+
];
|
|
248
|
+
const proposed = proposedLabels(currentLabels, assessment, configuration);
|
|
249
|
+
const status = errors.length ? 'invalid' : warnings.length ? 'blocked' : 'ready';
|
|
250
|
+
return {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
status,
|
|
253
|
+
valid: errors.length === 0,
|
|
254
|
+
canApply: status === 'ready',
|
|
255
|
+
workItem: input.workItem ?? null,
|
|
256
|
+
assessment: {
|
|
257
|
+
size: normalizedValue('size', assessment.size),
|
|
258
|
+
urgency: normalizedValue('urgency', assessment.urgency),
|
|
259
|
+
impact: normalizedValue('impact', assessment.impact),
|
|
260
|
+
risk: normalizedValue('risk', assessment.risk),
|
|
261
|
+
blastRadius: normalizedValue('blast-radius', assessment.blastRadius),
|
|
262
|
+
dependencies: assessment.dependencies ?? [],
|
|
263
|
+
route: assessment.route ?? null,
|
|
264
|
+
owner: assessment.owner ?? null,
|
|
265
|
+
confidence: assessment.confidence ?? {},
|
|
266
|
+
evidence,
|
|
267
|
+
},
|
|
268
|
+
transition: { from: state.value, to: assessment.nextState ?? null },
|
|
269
|
+
labels: { current: currentLabels, proposed },
|
|
270
|
+
missingEvidence,
|
|
271
|
+
unavailableChecks,
|
|
272
|
+
pendingReview,
|
|
273
|
+
suggestedColors: suggestedLabelColors,
|
|
274
|
+
errors,
|
|
275
|
+
warnings,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function applyTriageLabels(report) {
|
|
280
|
+
if (!report?.canApply) throw new Error('Triage report is not ready to apply. Resolve its errors or outstanding evidence state first.');
|
|
281
|
+
return [...report.labels.proposed];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export { DIMENSIONS, ROUTES, validateConfiguration };
|