@dogfood-lab/findings 1.2.2 → 1.2.3
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/advise/advice-bundle.js +155 -155
- package/advise/index.js +5 -5
- package/advise/query.js +182 -182
- package/derive/dedupe.js +107 -107
- package/derive/derive-findings.js +187 -187
- package/derive/ids.js +48 -48
- package/derive/index.js +9 -9
- package/derive/load-records.js +153 -153
- package/derive/rules.js +415 -415
- package/derive/write-findings.js +63 -63
- package/index.js +11 -11
- package/lib/file-lock.js +359 -359
- package/package.json +2 -2
- package/reader.js +156 -156
- package/review/index.js +6 -6
- package/review/transitions.js +79 -79
- package/synthesis/doctrine-derivation.js +128 -128
- package/synthesis/index.js +8 -8
- package/synthesis/pattern-derivation.js +184 -184
- package/synthesis/recommendation-derivation.js +156 -156
- package/synthesis/validate-artifacts.js +46 -46
- package/synthesis/write-artifacts.js +75 -75
- package/validate.js +87 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/findings",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"js-yaml": "^4.1.0"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
|
-
"node": ">=
|
|
49
|
+
"node": ">=22"
|
|
50
50
|
},
|
|
51
51
|
"author": "mcp-tool-shop",
|
|
52
52
|
"license": "MIT",
|
package/reader.js
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Finding reader/lister.
|
|
3
|
-
* Discovers findings from the filesystem, supports filtering and lookup.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdirSync, existsSync, statSync } from 'node:fs';
|
|
7
|
-
import { resolve, join, basename, extname } from 'node:path';
|
|
8
|
-
import { parseFinding, validateFinding } from './validate.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Discover all .yaml finding files under a root directory.
|
|
12
|
-
* Walks findings/<org>/<repo>/*.yaml
|
|
13
|
-
*
|
|
14
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
15
|
-
* @returns {string[]} Array of absolute paths to finding files.
|
|
16
|
-
*/
|
|
17
|
-
export function discoverFindings(rootDir) {
|
|
18
|
-
const findingsDir = resolve(rootDir, 'findings');
|
|
19
|
-
if (!existsSync(findingsDir)) return [];
|
|
20
|
-
|
|
21
|
-
const paths = [];
|
|
22
|
-
|
|
23
|
-
// Walk: findings/<org>/<repo>/*.yaml
|
|
24
|
-
for (const org of listDirs(findingsDir)) {
|
|
25
|
-
const orgDir = join(findingsDir, org);
|
|
26
|
-
for (const repo of listDirs(orgDir)) {
|
|
27
|
-
const repoDir = join(orgDir, repo);
|
|
28
|
-
for (const file of readdirSync(repoDir)) {
|
|
29
|
-
if (extname(file) === '.yaml') {
|
|
30
|
-
paths.push(resolve(repoDir, file));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return paths.sort();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Discover finding files from fixtures directory.
|
|
41
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
42
|
-
* @param {'valid' | 'invalid'} kind - Which fixture set.
|
|
43
|
-
* @returns {string[]} Array of absolute paths.
|
|
44
|
-
*/
|
|
45
|
-
export function discoverFixtures(rootDir, kind) {
|
|
46
|
-
const dir = resolve(rootDir, 'fixtures', 'findings', kind);
|
|
47
|
-
if (!existsSync(dir)) return [];
|
|
48
|
-
|
|
49
|
-
return readdirSync(dir)
|
|
50
|
-
.filter(f => extname(f) === '.yaml')
|
|
51
|
-
.map(f => resolve(dir, f))
|
|
52
|
-
.sort();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Load all findings from disk (real or fixtures).
|
|
57
|
-
* Returns parsed + validated findings.
|
|
58
|
-
*
|
|
59
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
60
|
-
* @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
|
|
61
|
-
* @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
|
|
62
|
-
*/
|
|
63
|
-
export function loadFindings(rootDir, opts = {}) {
|
|
64
|
-
const paths = opts.fixtures
|
|
65
|
-
? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
|
|
66
|
-
: discoverFindings(rootDir);
|
|
67
|
-
|
|
68
|
-
return paths.map(filePath => {
|
|
69
|
-
const { data, error } = parseFinding(filePath);
|
|
70
|
-
if (error) {
|
|
71
|
-
return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
|
|
72
|
-
}
|
|
73
|
-
const result = validateFinding(data);
|
|
74
|
-
return { path: filePath, data, ...result };
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Find a single finding by its finding_id.
|
|
80
|
-
* Searches real findings first, then fixtures.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
83
|
-
* @param {string} findingId - The finding_id to look up.
|
|
84
|
-
* @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
|
|
85
|
-
*/
|
|
86
|
-
export function findById(rootDir, findingId) {
|
|
87
|
-
// Search real findings
|
|
88
|
-
for (const filePath of discoverFindings(rootDir)) {
|
|
89
|
-
const { data } = parseFinding(filePath);
|
|
90
|
-
if (data && data.finding_id === findingId) {
|
|
91
|
-
const result = validateFinding(data);
|
|
92
|
-
return { path: filePath, data, ...result };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Search valid fixtures
|
|
97
|
-
for (const filePath of discoverFixtures(rootDir, 'valid')) {
|
|
98
|
-
const { data } = parseFinding(filePath);
|
|
99
|
-
if (data && data.finding_id === findingId) {
|
|
100
|
-
const result = validateFinding(data);
|
|
101
|
-
return { path: filePath, data, ...result };
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Filter a list of loaded findings.
|
|
110
|
-
*
|
|
111
|
-
* @param {Array<{ data: object }>} findings - Loaded findings.
|
|
112
|
-
* @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
|
|
113
|
-
* @returns {Array}
|
|
114
|
-
*/
|
|
115
|
-
export function filterFindings(findings, filters = {}) {
|
|
116
|
-
return findings.filter(f => {
|
|
117
|
-
if (!f.data) return false;
|
|
118
|
-
if (filters.repo && f.data.repo !== filters.repo) return false;
|
|
119
|
-
if (filters.status && f.data.status !== filters.status) return false;
|
|
120
|
-
if (filters.surface && f.data.product_surface !== filters.surface) return false;
|
|
121
|
-
if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
|
|
122
|
-
if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
|
|
123
|
-
return true;
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Check for duplicate finding_ids across all findings.
|
|
129
|
-
* @param {Array<{ data: object, path: string }>} findings
|
|
130
|
-
* @returns {Array<{ findingId: string, paths: string[] }>}
|
|
131
|
-
*/
|
|
132
|
-
export function findDuplicates(findings) {
|
|
133
|
-
const seen = new Map();
|
|
134
|
-
for (const f of findings) {
|
|
135
|
-
if (!f.data || !f.data.finding_id) continue;
|
|
136
|
-
const id = f.data.finding_id;
|
|
137
|
-
if (!seen.has(id)) seen.set(id, []);
|
|
138
|
-
seen.get(id).push(f.path);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return Array.from(seen.entries())
|
|
142
|
-
.filter(([, paths]) => paths.length > 1)
|
|
143
|
-
.map(([findingId, paths]) => ({ findingId, paths }));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** List subdirectories of a directory. */
|
|
147
|
-
function listDirs(dir) {
|
|
148
|
-
if (!existsSync(dir)) return [];
|
|
149
|
-
return readdirSync(dir).filter(name => {
|
|
150
|
-
try {
|
|
151
|
-
return statSync(join(dir, name)).isDirectory();
|
|
152
|
-
} catch {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Finding reader/lister.
|
|
3
|
+
* Discovers findings from the filesystem, supports filtering and lookup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { resolve, join, basename, extname } from 'node:path';
|
|
8
|
+
import { parseFinding, validateFinding } from './validate.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover all .yaml finding files under a root directory.
|
|
12
|
+
* Walks findings/<org>/<repo>/*.yaml
|
|
13
|
+
*
|
|
14
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
15
|
+
* @returns {string[]} Array of absolute paths to finding files.
|
|
16
|
+
*/
|
|
17
|
+
export function discoverFindings(rootDir) {
|
|
18
|
+
const findingsDir = resolve(rootDir, 'findings');
|
|
19
|
+
if (!existsSync(findingsDir)) return [];
|
|
20
|
+
|
|
21
|
+
const paths = [];
|
|
22
|
+
|
|
23
|
+
// Walk: findings/<org>/<repo>/*.yaml
|
|
24
|
+
for (const org of listDirs(findingsDir)) {
|
|
25
|
+
const orgDir = join(findingsDir, org);
|
|
26
|
+
for (const repo of listDirs(orgDir)) {
|
|
27
|
+
const repoDir = join(orgDir, repo);
|
|
28
|
+
for (const file of readdirSync(repoDir)) {
|
|
29
|
+
if (extname(file) === '.yaml') {
|
|
30
|
+
paths.push(resolve(repoDir, file));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return paths.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Discover finding files from fixtures directory.
|
|
41
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
42
|
+
* @param {'valid' | 'invalid'} kind - Which fixture set.
|
|
43
|
+
* @returns {string[]} Array of absolute paths.
|
|
44
|
+
*/
|
|
45
|
+
export function discoverFixtures(rootDir, kind) {
|
|
46
|
+
const dir = resolve(rootDir, 'fixtures', 'findings', kind);
|
|
47
|
+
if (!existsSync(dir)) return [];
|
|
48
|
+
|
|
49
|
+
return readdirSync(dir)
|
|
50
|
+
.filter(f => extname(f) === '.yaml')
|
|
51
|
+
.map(f => resolve(dir, f))
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load all findings from disk (real or fixtures).
|
|
57
|
+
* Returns parsed + validated findings.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
60
|
+
* @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
|
|
61
|
+
* @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
|
|
62
|
+
*/
|
|
63
|
+
export function loadFindings(rootDir, opts = {}) {
|
|
64
|
+
const paths = opts.fixtures
|
|
65
|
+
? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
|
|
66
|
+
: discoverFindings(rootDir);
|
|
67
|
+
|
|
68
|
+
return paths.map(filePath => {
|
|
69
|
+
const { data, error } = parseFinding(filePath);
|
|
70
|
+
if (error) {
|
|
71
|
+
return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
|
|
72
|
+
}
|
|
73
|
+
const result = validateFinding(data);
|
|
74
|
+
return { path: filePath, data, ...result };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find a single finding by its finding_id.
|
|
80
|
+
* Searches real findings first, then fixtures.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
83
|
+
* @param {string} findingId - The finding_id to look up.
|
|
84
|
+
* @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
|
|
85
|
+
*/
|
|
86
|
+
export function findById(rootDir, findingId) {
|
|
87
|
+
// Search real findings
|
|
88
|
+
for (const filePath of discoverFindings(rootDir)) {
|
|
89
|
+
const { data } = parseFinding(filePath);
|
|
90
|
+
if (data && data.finding_id === findingId) {
|
|
91
|
+
const result = validateFinding(data);
|
|
92
|
+
return { path: filePath, data, ...result };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Search valid fixtures
|
|
97
|
+
for (const filePath of discoverFixtures(rootDir, 'valid')) {
|
|
98
|
+
const { data } = parseFinding(filePath);
|
|
99
|
+
if (data && data.finding_id === findingId) {
|
|
100
|
+
const result = validateFinding(data);
|
|
101
|
+
return { path: filePath, data, ...result };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Filter a list of loaded findings.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{ data: object }>} findings - Loaded findings.
|
|
112
|
+
* @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
|
|
113
|
+
* @returns {Array}
|
|
114
|
+
*/
|
|
115
|
+
export function filterFindings(findings, filters = {}) {
|
|
116
|
+
return findings.filter(f => {
|
|
117
|
+
if (!f.data) return false;
|
|
118
|
+
if (filters.repo && f.data.repo !== filters.repo) return false;
|
|
119
|
+
if (filters.status && f.data.status !== filters.status) return false;
|
|
120
|
+
if (filters.surface && f.data.product_surface !== filters.surface) return false;
|
|
121
|
+
if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
|
|
122
|
+
if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check for duplicate finding_ids across all findings.
|
|
129
|
+
* @param {Array<{ data: object, path: string }>} findings
|
|
130
|
+
* @returns {Array<{ findingId: string, paths: string[] }>}
|
|
131
|
+
*/
|
|
132
|
+
export function findDuplicates(findings) {
|
|
133
|
+
const seen = new Map();
|
|
134
|
+
for (const f of findings) {
|
|
135
|
+
if (!f.data || !f.data.finding_id) continue;
|
|
136
|
+
const id = f.data.finding_id;
|
|
137
|
+
if (!seen.has(id)) seen.set(id, []);
|
|
138
|
+
seen.get(id).push(f.path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Array.from(seen.entries())
|
|
142
|
+
.filter(([, paths]) => paths.length > 1)
|
|
143
|
+
.map(([findingId, paths]) => ({ findingId, paths }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** List subdirectories of a directory. */
|
|
147
|
+
function listDirs(dir) {
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
return readdirSync(dir).filter(name => {
|
|
150
|
+
try {
|
|
151
|
+
return statSync(join(dir, name)).isDirectory();
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
package/review/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Review system exports.
|
|
3
|
-
*/
|
|
4
|
-
export { isLawfulTransition, validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED } from './transitions.js';
|
|
5
|
-
export { createEvent, appendEvent, getEventsForFinding, getAllEvents, getLogPath, generateEventId } from './event-log.js';
|
|
6
|
-
export { performAction, performMerge, getReviewQueue } from './review-engine.js';
|
|
1
|
+
/**
|
|
2
|
+
* Review system exports.
|
|
3
|
+
*/
|
|
4
|
+
export { isLawfulTransition, validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED } from './transitions.js';
|
|
5
|
+
export { createEvent, appendEvent, getEventsForFinding, getAllEvents, getLogPath, generateEventId } from './event-log.js';
|
|
6
|
+
export { performAction, performMerge, getReviewQueue } from './review-engine.js';
|
package/review/transitions.js
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Status transition law for findings.
|
|
3
|
-
*
|
|
4
|
-
* Lawful transitions:
|
|
5
|
-
* candidate -> reviewed, accepted, rejected
|
|
6
|
-
* reviewed -> accepted, rejected
|
|
7
|
-
* accepted -> reviewed (via reopen/invalidate only)
|
|
8
|
-
* accepted -> rejected (via invalidation/reversal only)
|
|
9
|
-
* rejected -> reviewed (via reopen only)
|
|
10
|
-
*
|
|
11
|
-
* Forbidden:
|
|
12
|
-
* rejected -> candidate (no rewinding to machine output)
|
|
13
|
-
* any status -> candidate (except initial creation)
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const TRANSITIONS = {
|
|
17
|
-
candidate: new Set(['reviewed', 'accepted', 'rejected']),
|
|
18
|
-
reviewed: new Set(['accepted', 'rejected']),
|
|
19
|
-
accepted: new Set(['reviewed', 'rejected']),
|
|
20
|
-
rejected: new Set(['reviewed'])
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Check if a status transition is lawful.
|
|
25
|
-
* @param {string} from - Current status.
|
|
26
|
-
* @param {string} to - Desired status.
|
|
27
|
-
* @returns {boolean}
|
|
28
|
-
*/
|
|
29
|
-
export function isLawfulTransition(from, to) {
|
|
30
|
-
const allowed = TRANSITIONS[from];
|
|
31
|
-
if (!allowed) return false;
|
|
32
|
-
return allowed.has(to);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Validate a transition and return error if invalid.
|
|
37
|
-
* @param {string} from
|
|
38
|
-
* @param {string} to
|
|
39
|
-
* @returns {{ valid: boolean, error?: string }}
|
|
40
|
-
*/
|
|
41
|
-
export function validateTransition(from, to) {
|
|
42
|
-
if (!TRANSITIONS[from]) {
|
|
43
|
-
return { valid: false, error: `Unknown status: "${from}"` };
|
|
44
|
-
}
|
|
45
|
-
if (!isLawfulTransition(from, to)) {
|
|
46
|
-
const allowed = [...TRANSITIONS[from]].join(', ');
|
|
47
|
-
return { valid: false, error: `Cannot transition from "${from}" to "${to}". Allowed: ${allowed}` };
|
|
48
|
-
}
|
|
49
|
-
return { valid: true };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Map review actions to their target statuses.
|
|
54
|
-
*/
|
|
55
|
-
export const ACTION_TARGET_STATUS = {
|
|
56
|
-
review: 'reviewed',
|
|
57
|
-
accept: 'accepted',
|
|
58
|
-
reject: 'rejected',
|
|
59
|
-
edit: null, // edit preserves current status
|
|
60
|
-
merge: 'rejected', // merged sources become rejected (merged_into_canonical)
|
|
61
|
-
reopen: 'reviewed',
|
|
62
|
-
invalidate: 'reviewed', // invalidated accepted → back to reviewed with invalidation metadata
|
|
63
|
-
supersede: 'rejected' // superseded finding becomes rejected
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Actions that require a reason.
|
|
68
|
-
*/
|
|
69
|
-
export const REASON_REQUIRED = new Set(['reject', 'invalidate', 'merge', 'supersede']);
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Actions that require from_status to be accepted.
|
|
73
|
-
*/
|
|
74
|
-
export const REQUIRES_ACCEPTED = new Set(['invalidate']);
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Actions that require from_status to be accepted or rejected.
|
|
78
|
-
*/
|
|
79
|
-
export const REQUIRES_CLOSED = new Set(['reopen']);
|
|
1
|
+
/**
|
|
2
|
+
* Status transition law for findings.
|
|
3
|
+
*
|
|
4
|
+
* Lawful transitions:
|
|
5
|
+
* candidate -> reviewed, accepted, rejected
|
|
6
|
+
* reviewed -> accepted, rejected
|
|
7
|
+
* accepted -> reviewed (via reopen/invalidate only)
|
|
8
|
+
* accepted -> rejected (via invalidation/reversal only)
|
|
9
|
+
* rejected -> reviewed (via reopen only)
|
|
10
|
+
*
|
|
11
|
+
* Forbidden:
|
|
12
|
+
* rejected -> candidate (no rewinding to machine output)
|
|
13
|
+
* any status -> candidate (except initial creation)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TRANSITIONS = {
|
|
17
|
+
candidate: new Set(['reviewed', 'accepted', 'rejected']),
|
|
18
|
+
reviewed: new Set(['accepted', 'rejected']),
|
|
19
|
+
accepted: new Set(['reviewed', 'rejected']),
|
|
20
|
+
rejected: new Set(['reviewed'])
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if a status transition is lawful.
|
|
25
|
+
* @param {string} from - Current status.
|
|
26
|
+
* @param {string} to - Desired status.
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isLawfulTransition(from, to) {
|
|
30
|
+
const allowed = TRANSITIONS[from];
|
|
31
|
+
if (!allowed) return false;
|
|
32
|
+
return allowed.has(to);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a transition and return error if invalid.
|
|
37
|
+
* @param {string} from
|
|
38
|
+
* @param {string} to
|
|
39
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
40
|
+
*/
|
|
41
|
+
export function validateTransition(from, to) {
|
|
42
|
+
if (!TRANSITIONS[from]) {
|
|
43
|
+
return { valid: false, error: `Unknown status: "${from}"` };
|
|
44
|
+
}
|
|
45
|
+
if (!isLawfulTransition(from, to)) {
|
|
46
|
+
const allowed = [...TRANSITIONS[from]].join(', ');
|
|
47
|
+
return { valid: false, error: `Cannot transition from "${from}" to "${to}". Allowed: ${allowed}` };
|
|
48
|
+
}
|
|
49
|
+
return { valid: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Map review actions to their target statuses.
|
|
54
|
+
*/
|
|
55
|
+
export const ACTION_TARGET_STATUS = {
|
|
56
|
+
review: 'reviewed',
|
|
57
|
+
accept: 'accepted',
|
|
58
|
+
reject: 'rejected',
|
|
59
|
+
edit: null, // edit preserves current status
|
|
60
|
+
merge: 'rejected', // merged sources become rejected (merged_into_canonical)
|
|
61
|
+
reopen: 'reviewed',
|
|
62
|
+
invalidate: 'reviewed', // invalidated accepted → back to reviewed with invalidation metadata
|
|
63
|
+
supersede: 'rejected' // superseded finding becomes rejected
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Actions that require a reason.
|
|
68
|
+
*/
|
|
69
|
+
export const REASON_REQUIRED = new Set(['reject', 'invalidate', 'merge', 'supersede']);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Actions that require from_status to be accepted.
|
|
73
|
+
*/
|
|
74
|
+
export const REQUIRES_ACCEPTED = new Set(['invalidate']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Actions that require from_status to be accepted or rejected.
|
|
78
|
+
*/
|
|
79
|
+
export const REQUIRES_CLOSED = new Set(['reopen']);
|