@jrpool/kilotest 61.0.0 → 62.0.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/.devin/config.local.json +2 -1
- package/.markdownlint-cli2.jsonc +0 -1
- package/api/listDiagnoses.ts +13 -3
- package/api/listIssues.ts +8 -4
- package/api/listViolators.ts +5 -3
- package/api/reportDisagreement.test.ts +208 -0
- package/api/reportDisagreement.ts +208 -0
- package/api/routes.test.ts +3 -2
- package/api/routes.ts +20 -1
- package/api/schemas.ts +44 -2
- package/apiIntegration.test.ts +22 -0
- package/docs/SERVICE.md +6 -3
- package/env.example +5 -0
- package/index.ts +14 -1
- package/lhm.plugin.json +337 -7
- package/llms-full.txt +13 -0
- package/llms.txt +1 -0
- package/mcp.test.ts +23 -4
- package/mcp.ts +24 -1
- package/openapi-3.0.yaml +187 -5
- package/openapi.yaml +183 -5
- package/package.json +1 -1
- package/server.json +1 -1
package/.devin/config.local.json
CHANGED
package/.markdownlint-cli2.jsonc
CHANGED
package/api/listDiagnoses.ts
CHANGED
|
@@ -94,14 +94,24 @@ export const response = async (args: string[]) => {
|
|
|
94
94
|
const diagnoses: Diagnoses = [];
|
|
95
95
|
// For each instance reporting a violation of the issue by the violator:
|
|
96
96
|
getTestActInstances(report, {violationsOnly: true, issueID, catalogIndex})
|
|
97
|
-
.forEach(({instance}) => {
|
|
97
|
+
.forEach(({act, instance}) => {
|
|
98
98
|
const {count, ordinalSeverity, ruleID, what} = instance;
|
|
99
99
|
// Get the diagnosis.
|
|
100
|
-
const diagnosis = {
|
|
100
|
+
const diagnosis: Diagnoses[number] = {
|
|
101
|
+
'identifier of the rule engine': act.which!,
|
|
101
102
|
'identifier of the violated rule': ruleID !== what ? ruleID : null,
|
|
102
103
|
'description of the violation': what,
|
|
103
104
|
'severity of the violation on a 0-to-3 scale': ordinalSeverity,
|
|
104
|
-
'count of violations of the rule by the element': count ?? 1
|
|
105
|
+
'count of violations of the rule by the element': count ?? 1,
|
|
106
|
+
'how to report that you disagree with this diagnosis': {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
URL: `${thisHost}/api/reportDisagreement/${issueID}/${reportID}/${catalogIndex}`,
|
|
109
|
+
'request body': {
|
|
110
|
+
engineID: 'the identifier of the rule engine shown in this diagnosis',
|
|
111
|
+
ruleID: 'the identifier of the violated rule shown in this diagnosis, or, if it is null, the description of the violation',
|
|
112
|
+
reason: '20- to 1000-character explanation of why you disagree with this diagnosis'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
105
115
|
}
|
|
106
116
|
diagnoses.push(diagnosis);
|
|
107
117
|
});
|
package/api/listIssues.ts
CHANGED
|
@@ -31,6 +31,10 @@ import {listIssuesResponseSchema} from './schemas.ts';
|
|
|
31
31
|
|
|
32
32
|
// The response content defined by the response schema.
|
|
33
33
|
type ResponseContent = z.infer<typeof listIssuesResponseSchema>['response content'];
|
|
34
|
+
// One entry of the history of reports about the page.
|
|
35
|
+
type ReportHistoryEntry = NonNullable<ResponseContent['history of reports about the page']>[number];
|
|
36
|
+
// The basics about one issue in the report.
|
|
37
|
+
type IssueBasics = NonNullable<ResponseContent['basics about all issues reported in the report']>[number];
|
|
34
38
|
|
|
35
39
|
// FUNCTIONS
|
|
36
40
|
|
|
@@ -67,7 +71,7 @@ export const response = async (args: string[]) => {
|
|
|
67
71
|
const pageExtracts = await getPageReportExtracts(report.target.what);
|
|
68
72
|
const lastIndex = pageExtracts.length - 1;
|
|
69
73
|
// Add the history of reports about the page to the response content.
|
|
70
|
-
responseContent['history of reports about the page'] = pageExtracts.map((extract, index) => {
|
|
74
|
+
responseContent['history of reports about the page'] = pageExtracts.map((extract, index): ReportHistoryEntry => {
|
|
71
75
|
const extractID = extract.id;
|
|
72
76
|
return {
|
|
73
77
|
identifier: extractID,
|
|
@@ -77,7 +81,7 @@ export const response = async (args: string[]) => {
|
|
|
77
81
|
'whether it is the latest report about the page': index === lastIndex,
|
|
78
82
|
'whether it is the report described in this response': extractID === reportID,
|
|
79
83
|
'how to get details about the report': {
|
|
80
|
-
method: 'GET'
|
|
84
|
+
method: 'GET',
|
|
81
85
|
URL: `${thisHost}/api/listIssues/${extractID}`
|
|
82
86
|
},
|
|
83
87
|
'web users can get details about the report at': `${thisHost}/listIssues.html/${extractID}`
|
|
@@ -210,7 +214,7 @@ export const response = async (args: string[]) => {
|
|
|
210
214
|
// Sort the data about issues by summary.
|
|
211
215
|
const sortedIssuesData = objectSort(Object.values(issuesData), 'summary', 'alpha');
|
|
212
216
|
// Get the basics about the issues.
|
|
213
|
-
const issuesBasics = sortedIssuesData.map(issueData => {
|
|
217
|
+
const issuesBasics = sortedIssuesData.map((issueData): IssueBasics => {
|
|
214
218
|
const {id, summary, weight, why, reporterIDs} = issueData;
|
|
215
219
|
return {
|
|
216
220
|
identifier: id,
|
|
@@ -220,7 +224,7 @@ export const response = async (args: string[]) => {
|
|
|
220
224
|
'rule engines with any violations belonging to the issue': getRuleEnginesFacts(reporterIDs)
|
|
221
225
|
.map(ruleEnginesFact => ruleEnginesFact.name),
|
|
222
226
|
'how to get details about the issue': {
|
|
223
|
-
method: 'GET'
|
|
227
|
+
method: 'GET',
|
|
224
228
|
URL: `${thisHost}/api/listViolators/${id}/${reportID}`
|
|
225
229
|
},
|
|
226
230
|
'web users can get details about the issue at': `${thisHost}/listViolators.html/${id}/${reportID}`
|
package/api/listViolators.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {listViolatorsResponseSchema} from './schemas.ts';
|
|
|
21
21
|
|
|
22
22
|
// The response content defined by the response schema.
|
|
23
23
|
type ResponseContent = z.infer<typeof listViolatorsResponseSchema>['response content'];
|
|
24
|
+
// The basics about one element exhibiting the issue.
|
|
25
|
+
type ViolatorBasics = NonNullable<ResponseContent['basics about all elements exhibiting the issue']>[number];
|
|
24
26
|
|
|
25
27
|
// FUNCTIONS
|
|
26
28
|
|
|
@@ -96,7 +98,7 @@ export const response = async (args: string[]) => {
|
|
|
96
98
|
);
|
|
97
99
|
// Get basics about the violators.
|
|
98
100
|
const violatorsBasics = sortedViolatorData
|
|
99
|
-
.map(violator => {
|
|
101
|
+
.map((violator): ViolatorBasics => {
|
|
100
102
|
const {catalogIndex, reporters} = violator;
|
|
101
103
|
const catalogItem = catalog[catalogIndex];
|
|
102
104
|
return {
|
|
@@ -105,8 +107,8 @@ export const response = async (args: string[]) => {
|
|
|
105
107
|
'inner text': catalogItem?.text ?? null,
|
|
106
108
|
'count of rule engines reporting that the element exhibited the issue': reporters.size,
|
|
107
109
|
'how to get details about the element': {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
method: 'GET',
|
|
111
|
+
URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`
|
|
110
112
|
},
|
|
111
113
|
'web users can get details about the element at': `${thisHost}/listDiagnoses.html/${issueID}/${reportID}/${catalogIndex}`
|
|
112
114
|
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
reportDisagreement.test.ts
|
|
3
|
+
Tests for api/reportDisagreement.ts using the fixture corpus, with mocked side effects.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {test, before, beforeEach, after} from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
// Blank the alert configuration unconditionally, before ./reportDisagreement.ts (which
|
|
14
|
+
// calls sendAlert) is ever imported below, so this file sends no real alert emails
|
|
15
|
+
// even when run directly rather than via `npm test`.
|
|
16
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
17
|
+
process.env[key] = '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ENVIRONMENT
|
|
21
|
+
|
|
22
|
+
const testDir = path.join(import.meta.dirname, '../test/fixtures/disagreements');
|
|
23
|
+
process.env.DISAGREEMENTS_PATH = path.join(testDir, 'disagreements.json');
|
|
24
|
+
|
|
25
|
+
// SETUP AND TEARDOWN
|
|
26
|
+
|
|
27
|
+
const savedDBDir = process.env.DB_DIR;
|
|
28
|
+
let logged: any[] = [];
|
|
29
|
+
const originalLog = console.log;
|
|
30
|
+
|
|
31
|
+
before(async () => {
|
|
32
|
+
process.env.DB_DIR = (await import('../test/dbFixture.ts')).fixtureDBDir;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Capture console.log so the alert sendAlert emits can be observed. The alert
|
|
36
|
+
// configuration is blanked above, so sendAlert logs a WARNING instead of sending.
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
logged = [];
|
|
39
|
+
console.log = (...args) => logged.push(args.join(' '));
|
|
40
|
+
await fs.mkdir(testDir, {recursive: true});
|
|
41
|
+
await fs.writeFile(process.env.DISAGREEMENTS_PATH!, '[]\n');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
import {response} from './reportDisagreement.ts';
|
|
45
|
+
|
|
46
|
+
after(async () => {
|
|
47
|
+
console.log = originalLog;
|
|
48
|
+
if (savedDBDir !== undefined) {
|
|
49
|
+
process.env.DB_DIR = savedDBDir;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
delete process.env.DB_DIR;
|
|
53
|
+
}
|
|
54
|
+
await fs.rm(testDir, {recursive: true, force: true});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// TESTS
|
|
58
|
+
|
|
59
|
+
test('reportDisagreement rejects a reason shorter than 20 characters', async () => {
|
|
60
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'Too short']);
|
|
61
|
+
const details = body['response content']['details about your request'] as any;
|
|
62
|
+
assert.ok(details.error.includes('reason'));
|
|
63
|
+
assert.ok(
|
|
64
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: diagnosis disagreement received)'))
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('reportDisagreement rejects a reason longer than 1000 characters', async () => {
|
|
69
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'x'.repeat(1001)]);
|
|
70
|
+
const details = body['response content']['details about your request'] as any;
|
|
71
|
+
assert.ok(details.error.includes('reason'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('reportDisagreement rejects a nonexistent report', async () => {
|
|
75
|
+
const body = await response(['linkNoText', '999999T9999-xyz', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
|
|
76
|
+
const details = body['response content']['details about your request'] as any;
|
|
77
|
+
assert.ok(details.error.includes('report'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('reportDisagreement rejects an element not in the report', async () => {
|
|
81
|
+
const body = await response(['linkNoText', '260101T0000-mix', '999', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
|
|
82
|
+
const details = body['response content']['details about your request'] as any;
|
|
83
|
+
assert.ok(details.error.includes('element'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('reportDisagreement rejects a diagnosis no rule engine reported', async () => {
|
|
87
|
+
// The mixed report has axe and alfa diagnoses of rule r11 for element 0 and issue
|
|
88
|
+
// linkNoText, but no ibm diagnosis, so this engineID/ruleID pair matches nothing.
|
|
89
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'ibm', 'r11', 'This diagnosis is wrong, in my opinion']);
|
|
90
|
+
const details = body['response content']['details about your request'] as any;
|
|
91
|
+
assert.ok(details.error.includes('no such diagnosis'));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('reportDisagreement rejects a rule ID reported only under a different issue', async () => {
|
|
95
|
+
const body = await response(['focusIndicationBad', '260101T0000-mix', '0', 'axe', 'r11', 'This diagnosis is wrong, in my opinion']);
|
|
96
|
+
const details = body['response content']['details about your request'] as any;
|
|
97
|
+
assert.ok(details.error.includes('no such diagnosis'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('reportDisagreement accepts a valid disagreement and notifies the manager', async () => {
|
|
101
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
102
|
+
const details = body['response content']['details about your request'] as any;
|
|
103
|
+
assert.equal(details.error, undefined);
|
|
104
|
+
assert.ok(details['date and time received']);
|
|
105
|
+
assert.equal(details['diagnosis you disagree with']['identifier of the rule engine'], 'axe');
|
|
106
|
+
assert.equal(details['diagnosis you disagree with']['identifier of the violated rule'], 'r11');
|
|
107
|
+
assert.equal(details.disposition, 'received and logged; manager notified');
|
|
108
|
+
assert.ok(logged.some(line =>
|
|
109
|
+
line.startsWith('WARNING (Kilotest: diagnosis disagreement received)')
|
|
110
|
+
&& line.includes('Disagreements now awaiting review: 1 of 20')
|
|
111
|
+
));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('reportDisagreement records a valid disagreement to the disagreements file', async () => {
|
|
115
|
+
await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
|
|
116
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
117
|
+
assert.equal(disagreements.length, 1);
|
|
118
|
+
assert.equal(disagreements[0].engineID, 'alfa');
|
|
119
|
+
assert.equal(disagreements[0].ruleID, 'r11');
|
|
120
|
+
assert.equal(disagreements[0].issueID, 'linkNoText');
|
|
121
|
+
assert.ok(disagreements[0].timeStamp);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('reportDisagreement rejects a disagreement identical to one already on file', async () => {
|
|
125
|
+
const args = ['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name'];
|
|
126
|
+
const firstBody = await response(args);
|
|
127
|
+
const firstDetails = firstBody['response content']['details about your request'] as any;
|
|
128
|
+
assert.equal(firstDetails.error, undefined);
|
|
129
|
+
const secondBody = await response(args);
|
|
130
|
+
const secondDetails = secondBody['response content']['details about your request'] as any;
|
|
131
|
+
assert.ok(secondDetails.error.includes('identical to one already submitted'));
|
|
132
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
133
|
+
// The duplicate was not recorded a second time.
|
|
134
|
+
assert.equal(disagreements.length, 1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('reportDisagreement accepts the same reason about a different diagnosis', async () => {
|
|
138
|
+
await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
139
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'alfa', 'r11', 'The element does have an accessible name']);
|
|
140
|
+
const details = body['response content']['details about your request'] as any;
|
|
141
|
+
assert.equal(details.error, undefined);
|
|
142
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
143
|
+
assert.equal(disagreements.length, 2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('reportDisagreement creates the disagreements file when it does not exist', async () => {
|
|
147
|
+
await fs.unlink(process.env.DISAGREEMENTS_PATH!);
|
|
148
|
+
await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
149
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
150
|
+
assert.equal(disagreements.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('reportDisagreement rejects a disagreement with a fallback channel when the inbox is full', async () => {
|
|
154
|
+
// Fill disagreements.json with 20 distinct disagreements, reaching the cap.
|
|
155
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
156
|
+
timeStamp: '260101T0000',
|
|
157
|
+
issueID: 'linkNoText',
|
|
158
|
+
reportID: '260101T0000-mix',
|
|
159
|
+
catalogIndex: '0',
|
|
160
|
+
engineID: 'axe',
|
|
161
|
+
ruleID: `r${i}`,
|
|
162
|
+
reason: `Filler disagreement number ${i}, long enough to pass the length check.`
|
|
163
|
+
}));
|
|
164
|
+
await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
|
|
165
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
166
|
+
const details = body['response content']['details about your request'] as any;
|
|
167
|
+
assert.ok(details.error.includes('too many disagreements are awaiting review right now.'));
|
|
168
|
+
assert.ok(details.error.includes('https://github.com/jrpool/kilotest/issues'));
|
|
169
|
+
assert.ok(details.error.includes('info@kilotest.com'));
|
|
170
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
171
|
+
// The rejected disagreement was not recorded.
|
|
172
|
+
assert.equal(disagreements.length, 20);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('reportDisagreement never rejects for a full inbox when DISAGREEMENTS_MAX is 0 (no limit)', async () => {
|
|
176
|
+
process.env.DISAGREEMENTS_MAX = '0';
|
|
177
|
+
try {
|
|
178
|
+
// Fill disagreements.json past the default cap.
|
|
179
|
+
const filler = Array.from({length: 25}, (_, i) => ({
|
|
180
|
+
timeStamp: '260101T0000',
|
|
181
|
+
issueID: 'linkNoText',
|
|
182
|
+
reportID: '260101T0000-mix',
|
|
183
|
+
catalogIndex: '0',
|
|
184
|
+
engineID: 'axe',
|
|
185
|
+
ruleID: `r${i}`,
|
|
186
|
+
reason: `Filler disagreement number ${i}, long enough to pass the length check.`
|
|
187
|
+
}));
|
|
188
|
+
await fs.writeFile(process.env.DISAGREEMENTS_PATH!, JSON.stringify(filler));
|
|
189
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
190
|
+
const details = body['response content']['details about your request'] as any;
|
|
191
|
+
assert.equal(details.error, undefined);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
delete process.env.DISAGREEMENTS_MAX;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('reportDisagreement does not record an invalid disagreement', async () => {
|
|
199
|
+
await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'short']);
|
|
200
|
+
const disagreements = JSON.parse(await fs.readFile(process.env.DISAGREEMENTS_PATH!, 'utf8'));
|
|
201
|
+
assert.equal(disagreements.length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('reportDisagreement includes tool name and metadata', async () => {
|
|
205
|
+
const body = await response(['linkNoText', '260101T0000-mix', '0', 'axe', 'r11', 'The element does have an accessible name']);
|
|
206
|
+
assert.equal(body['tool name'], 'reportDisagreement');
|
|
207
|
+
assert.ok(body['response metadata'].identifier);
|
|
208
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
reportDisagreement.ts
|
|
3
|
+
Processes a report that the requester disagrees with a diagnosis and returns an acknowledgement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {z} from 'zod';
|
|
9
|
+
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {sendAlert} from '../alerts.ts';
|
|
11
|
+
import {
|
|
12
|
+
checkLength,
|
|
13
|
+
describeMax,
|
|
14
|
+
getEnvMax,
|
|
15
|
+
getJSON,
|
|
16
|
+
getNowStamp,
|
|
17
|
+
getReport,
|
|
18
|
+
getTestActInstances,
|
|
19
|
+
isReportError
|
|
20
|
+
} from '../util.ts';
|
|
21
|
+
import {reportDisagreementResponseSchema} from './schemas.ts';
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
// TYPES
|
|
26
|
+
|
|
27
|
+
// The response content defined by the response schema.
|
|
28
|
+
type ResponseContent = z.infer<typeof reportDisagreementResponseSchema>['response content'];
|
|
29
|
+
|
|
30
|
+
// A stored disagreement with a diagnosis.
|
|
31
|
+
type Disagreement = {
|
|
32
|
+
timeStamp: string;
|
|
33
|
+
issueID: string;
|
|
34
|
+
reportID: string;
|
|
35
|
+
catalogIndex: string;
|
|
36
|
+
engineID: string;
|
|
37
|
+
ruleID: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// FUNCTIONS (helpers)
|
|
42
|
+
|
|
43
|
+
const getDisagreementsPath = () => process.env.DISAGREEMENTS_PATH || path.join(import.meta.dirname, '../db/disagreements.json');
|
|
44
|
+
// Maximum number of diagnosis disagreements held at once. Once reached, a new, distinct
|
|
45
|
+
// disagreement is rejected rather than stored, so the disagreements file and the manager
|
|
46
|
+
// alert emails it generates cannot grow without bound (modeled on the feature-request
|
|
47
|
+
// cap in requestFeature.ts). Configurable via DISAGREEMENTS_MAX; a value of 0 means no
|
|
48
|
+
// limit.
|
|
49
|
+
const getDisagreementsMax = () => getEnvMax('DISAGREEMENTS_MAX', 20);
|
|
50
|
+
|
|
51
|
+
// FUNCTIONS
|
|
52
|
+
|
|
53
|
+
// Returns the response body.
|
|
54
|
+
export const response = async (args: string[]) => {
|
|
55
|
+
const [issueID = '', reportID = '', catalogIndex = '', engineID = '', ruleID = '', reason = ''] = args;
|
|
56
|
+
const thisHost = getThisHost();
|
|
57
|
+
const reasonCheck = checkLength(reason, 20, 1000, 'reason');
|
|
58
|
+
// Initialize the response content.
|
|
59
|
+
let responseContent: ResponseContent;
|
|
60
|
+
// If the reason is too short or too long:
|
|
61
|
+
if (reasonCheck.status === 'error') {
|
|
62
|
+
responseContent = {
|
|
63
|
+
'details about your request': {
|
|
64
|
+
error: `request invalid: your ${reasonCheck.message}`
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Otherwise, i.e. if it is valid:
|
|
69
|
+
else {
|
|
70
|
+
// Get the report.
|
|
71
|
+
const report = await getReport(reportID);
|
|
72
|
+
// If the report was not retrieved successfully:
|
|
73
|
+
if (isReportError(report)) {
|
|
74
|
+
responseContent = {
|
|
75
|
+
'details about your request': {
|
|
76
|
+
error: 'request invalid: the specified report does not exist or is not usable'
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Otherwise, if the element is not in the report's catalog:
|
|
81
|
+
else if (!report.catalog[catalogIndex]) {
|
|
82
|
+
responseContent = {
|
|
83
|
+
'details about your request': {
|
|
84
|
+
error: 'request invalid: the specified element is not in the report'
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Otherwise, i.e. if the report and element exist:
|
|
89
|
+
else {
|
|
90
|
+
// Get the reported diagnoses of the element for the issue matching the engine and
|
|
91
|
+
// rule (a ruleID is only unique within a rule engine: axe and alfa, for example,
|
|
92
|
+
// both report rules numbered like r11 for the same element and issue).
|
|
93
|
+
const matches = getTestActInstances(report, {violationsOnly: true, issueID, catalogIndex})
|
|
94
|
+
.filter(({act, instance}) => act.which === engineID && instance.ruleID === ruleID);
|
|
95
|
+
// If no reported diagnosis matches:
|
|
96
|
+
if (!matches.length) {
|
|
97
|
+
responseContent = {
|
|
98
|
+
'details about your request': {
|
|
99
|
+
error: 'request invalid: no such diagnosis was reported for the specified element and issue'
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Otherwise, i.e. if the diagnosis exists:
|
|
104
|
+
else {
|
|
105
|
+
// Get the disagreements already on file.
|
|
106
|
+
const disagreementsPath = getDisagreementsPath();
|
|
107
|
+
let disagreements: Disagreement[] = [];
|
|
108
|
+
try {
|
|
109
|
+
const existing = await fs.readFile(disagreementsPath, 'utf8');
|
|
110
|
+
disagreements = JSON.parse(existing);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Initialize an empty disagreements array.
|
|
114
|
+
}
|
|
115
|
+
// If the disagreement is identical to one already on file:
|
|
116
|
+
if (disagreements.some(d =>
|
|
117
|
+
d.issueID === issueID
|
|
118
|
+
&& d.reportID === reportID
|
|
119
|
+
&& d.catalogIndex === catalogIndex
|
|
120
|
+
&& d.engineID === engineID
|
|
121
|
+
&& d.ruleID === ruleID
|
|
122
|
+
&& d.reason === reason
|
|
123
|
+
)) {
|
|
124
|
+
responseContent = {
|
|
125
|
+
'details about your request': {
|
|
126
|
+
error: 'request invalid: your disagreement is identical to one already submitted, ' +
|
|
127
|
+
'but you are welcome to submit a different one'
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Otherwise, if the disagreements already on file have reached the cap (a cap of 0
|
|
132
|
+
// means no limit, so the queue is never full):
|
|
133
|
+
else if (getDisagreementsMax() > 0 && disagreements.length >= getDisagreementsMax()) {
|
|
134
|
+
responseContent = {
|
|
135
|
+
'details about your request': {
|
|
136
|
+
error: 'request invalid: too many disagreements are awaiting review right now. ' +
|
|
137
|
+
'Please try again later, or post your report at https://github.com/jrpool/kilotest/issues ' +
|
|
138
|
+
'or email info@kilotest.com.'
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Otherwise, i.e. if it is not a duplicate and the queue is not full:
|
|
143
|
+
else {
|
|
144
|
+
// Record the disagreement alongside the existing ones.
|
|
145
|
+
disagreements.push({
|
|
146
|
+
timeStamp: getNowStamp(),
|
|
147
|
+
issueID,
|
|
148
|
+
reportID,
|
|
149
|
+
catalogIndex,
|
|
150
|
+
engineID,
|
|
151
|
+
ruleID,
|
|
152
|
+
reason
|
|
153
|
+
});
|
|
154
|
+
await fs.mkdir(path.dirname(disagreementsPath), {recursive: true});
|
|
155
|
+
await fs.writeFile(disagreementsPath, getJSON(disagreements));
|
|
156
|
+
// Notify the manager, including the resulting count, so a maintainer who has been
|
|
157
|
+
// away sees at a glance how urgently the disagreements need review. The ruleID is
|
|
158
|
+
// truncated because some rule engines (nuVal, nuVnu) use entire validator messages
|
|
159
|
+
// as rule identifiers, which can be arbitrarily long.
|
|
160
|
+
await sendAlert(
|
|
161
|
+
'Kilotest: diagnosis disagreement received',
|
|
162
|
+
`Disputed diagnosis: ${engineID} rule ${ruleID.slice(0, 500)} classified as issue ${issueID} ` +
|
|
163
|
+
`on element ${catalogIndex} of report ${reportID}\nReason: ${reason}\n` +
|
|
164
|
+
`Disagreements now awaiting review: ${disagreements.length} of ${describeMax(getDisagreementsMax())}`
|
|
165
|
+
);
|
|
166
|
+
// Add the disposition to the response content.
|
|
167
|
+
responseContent = {
|
|
168
|
+
'details about your request': {
|
|
169
|
+
'date and time received': new Date().toISOString(),
|
|
170
|
+
'diagnosis you disagree with': {
|
|
171
|
+
'issue identifier': issueID,
|
|
172
|
+
'identifier of the rule engine': engineID,
|
|
173
|
+
'identifier of the violated rule': ruleID
|
|
174
|
+
},
|
|
175
|
+
disposition: 'received and logged; manager notified'
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Create a response body.
|
|
183
|
+
const body = {
|
|
184
|
+
'tool collection': getToolsFacts(),
|
|
185
|
+
'tool name': 'reportDisagreement',
|
|
186
|
+
'this request': {
|
|
187
|
+
description: 'Process my report that I disagree with a diagnosis. The issueID, reportID, and catalogIndex parameters identify the issue, report, and element of the diagnosis. They were in the response to my earlier listDiagnoses request. The engineID and ruleID properties of the request body identify which diagnosis I disagree with, and the reason property explains why I disagree with it.',
|
|
188
|
+
method: 'POST',
|
|
189
|
+
URL: `${thisHost}/api/reportDisagreement/${issueID}/${reportID}/${catalogIndex}`,
|
|
190
|
+
body: {
|
|
191
|
+
engineID,
|
|
192
|
+
ruleID,
|
|
193
|
+
reason
|
|
194
|
+
},
|
|
195
|
+
'closest ancestor request': {
|
|
196
|
+
'tool name': 'listDiagnoses',
|
|
197
|
+
description: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
|
|
198
|
+
method: 'GET',
|
|
199
|
+
URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
'URLs of similar requests for web users': null,
|
|
203
|
+
'response metadata': getResponseMetadata(),
|
|
204
|
+
'response content': responseContent
|
|
205
|
+
};
|
|
206
|
+
// Return it.
|
|
207
|
+
return body;
|
|
208
|
+
};
|
package/api/routes.test.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {routes} from './routes.ts';
|
|
|
11
11
|
|
|
12
12
|
// TESTS
|
|
13
13
|
|
|
14
|
-
test('routes exports an array with
|
|
14
|
+
test('routes exports an array with 12 operations', () => {
|
|
15
15
|
assert.ok(Array.isArray(routes));
|
|
16
|
-
assert.equal(routes.length,
|
|
16
|
+
assert.equal(routes.length, 12);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test('routes includes all expected operation IDs', () => {
|
|
@@ -27,6 +27,7 @@ test('routes includes all expected operation IDs', () => {
|
|
|
27
27
|
'listViolators',
|
|
28
28
|
'orderNewTest',
|
|
29
29
|
'orderRetest',
|
|
30
|
+
'reportDisagreement',
|
|
30
31
|
'requestFeature',
|
|
31
32
|
'requestNewTest',
|
|
32
33
|
'requestRetest'
|
package/api/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
orderNewTestSchema,
|
|
18
18
|
orderRetestSchema,
|
|
19
19
|
awaitTestSchema,
|
|
20
|
+
reportDisagreementSchema,
|
|
20
21
|
listPagesResponseSchema,
|
|
21
22
|
listIssuesResponseSchema,
|
|
22
23
|
listViolatorsResponseSchema,
|
|
@@ -27,7 +28,8 @@ import {
|
|
|
27
28
|
requestFeatureResponseSchema,
|
|
28
29
|
orderNewTestResponseSchema,
|
|
29
30
|
orderRetestResponseSchema,
|
|
30
|
-
awaitTestResponseSchema
|
|
31
|
+
awaitTestResponseSchema,
|
|
32
|
+
reportDisagreementResponseSchema
|
|
31
33
|
} from './schemas.ts';
|
|
32
34
|
|
|
33
35
|
// ROUTES
|
|
@@ -99,6 +101,23 @@ export const routes = [
|
|
|
99
101
|
bodySchema: z.object(requestFeatureSchema),
|
|
100
102
|
responseSchema: requestFeatureResponseSchema
|
|
101
103
|
},
|
|
104
|
+
{
|
|
105
|
+
operationId: 'reportDisagreement',
|
|
106
|
+
method: 'post',
|
|
107
|
+
path: '/api/reportDisagreement/{issueID}/{reportID}/{catalogIndex}',
|
|
108
|
+
summary: 'Report disagreement with a diagnosis',
|
|
109
|
+
pathParamsSchema: z.object({
|
|
110
|
+
issueID: reportDisagreementSchema.issueID,
|
|
111
|
+
reportID: reportDisagreementSchema.reportID,
|
|
112
|
+
catalogIndex: reportDisagreementSchema.catalogIndex
|
|
113
|
+
}),
|
|
114
|
+
bodySchema: z.object({
|
|
115
|
+
engineID: reportDisagreementSchema.engineID,
|
|
116
|
+
ruleID: reportDisagreementSchema.ruleID,
|
|
117
|
+
reason: reportDisagreementSchema.reason
|
|
118
|
+
}),
|
|
119
|
+
responseSchema: reportDisagreementResponseSchema
|
|
120
|
+
},
|
|
102
121
|
{
|
|
103
122
|
operationId: 'orderNewTest',
|
|
104
123
|
method: 'post',
|