@jrpool/kilotest 61.0.0 → 63.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.
@@ -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
+ };
@@ -37,6 +37,7 @@ beforeEach(async () => {
37
37
  });
38
38
 
39
39
  import {response} from './requestFeature.ts';
40
+ import {requestFeatureResponseSchema} from './schemas.ts';
40
41
 
41
42
  after(async () => {
42
43
  console.log = originalLog;
@@ -186,3 +187,11 @@ test('requestFeature accepts a feature request that differs from an existing one
186
187
  const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
187
188
  assert.equal(featureRequests.length, 2);
188
189
  });
190
+
191
+ test('requestFeature response conforms to its response schema', async () => {
192
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
193
+ // original catches extra keys that zod strips but that the strict JSON schema
194
+ // advertised to MCP clients (additionalProperties: false) rejects.
195
+ const body = await response(['A feature request submitted only for schema conformance.']);
196
+ assert.deepEqual(requestFeatureResponseSchema.parse(body), body);
197
+ });
@@ -42,6 +42,7 @@ beforeEach(async () => {
42
42
  });
43
43
 
44
44
  import {response} from './requestNewTest.ts';
45
+ import {requestNewTestResponseSchema} from './schemas.ts';
45
46
 
46
47
  after(() => {
47
48
  console.log = originalLog;
@@ -208,3 +209,11 @@ test('requestNewTest rejects a request with a fallback channel when the queue is
208
209
  assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
209
210
  assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
210
211
  });
212
+
213
+ test('requestNewTest response conforms to its response schema', async () => {
214
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
215
+ // original catches extra keys that zod strips but that the strict JSON schema
216
+ // advertised to MCP clients (additionalProperties: false) rejects.
217
+ const body = await response(['Schema Conformance Page', 'https://example.com/schema-conformance', 'A reason that is long enough.']);
218
+ assert.deepEqual(requestNewTestResponseSchema.parse(body), body);
219
+ });
@@ -56,6 +56,7 @@ beforeEach(async () => {
56
56
  });
57
57
 
58
58
  import {response} from './requestRetest.ts';
59
+ import {requestRetestResponseSchema} from './schemas.ts';
59
60
 
60
61
  after(async () => {
61
62
  console.log = originalLog;
@@ -193,3 +194,11 @@ test('requestRetest rejects a request with a fallback channel when the queue is
193
194
  assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
194
195
  assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
195
196
  });
197
+
198
+ test('requestRetest response conforms to its response schema', async () => {
199
+ // parse() catches missing or mistyped keys; comparing the parsed result to the
200
+ // original catches extra keys that zod strips but that the strict JSON schema
201
+ // advertised to MCP clients (additionalProperties: false) rejects.
202
+ const body = await response(['260101T0003-ret', 'A reason that is long enough.']);
203
+ assert.deepEqual(requestRetestResponseSchema.parse(body), body);
204
+ });
@@ -11,9 +11,9 @@ import {routes} from './routes.ts';
11
11
 
12
12
  // TESTS
13
13
 
14
- test('routes exports an array with 11 operations', () => {
14
+ test('routes exports an array with 12 operations', () => {
15
15
  assert.ok(Array.isArray(routes));
16
- assert.equal(routes.length, 11);
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',
package/api/schemas.ts CHANGED
@@ -73,6 +73,16 @@ export const awaitTestSchema = {
73
73
  reportID: z.string().describe('Report identifier returned by orderNewTest or orderRetest (example: 260503T0432-x9z)')
74
74
  };
75
75
 
76
+ // reportDisagreement: POST /api/reportDisagreement/{issueID}/{reportID}/{catalogIndex}
77
+ export const reportDisagreementSchema = {
78
+ issueID: z.string().describe('Issue identifier (example: contrastPoor)'),
79
+ reportID: z.string().describe('Identifier of the report (example: 260503T0432-x9z)'),
80
+ catalogIndex: z.string().describe('Identifier of the issue-exhibiting element in the catalog of elements on the page (example: 372)'),
81
+ engineID: z.string().describe('Identifier of the rule engine that produced the disputed diagnosis (example: axe), as shown in the listDiagnoses output'),
82
+ ruleID: z.string().describe('Identifier of the rule whose diagnosis is disputed, as shown in the listDiagnoses output (example: color-contrast); if that output shows null for the rule identifier, use its description of the violation instead'),
83
+ reason: z.string().describe('20- to 1000-character explanation of why the diagnosis is disputed')
84
+ };
85
+
76
86
  // listPages takes no input; omitted (mcp.ts already uses inputSchema: {}).
77
87
 
78
88
  // RESPONSE SCHEMAS
@@ -85,7 +95,7 @@ const ruleEngineFactsSchema = z.object({
85
95
  }).meta({id: 'RuleEngineFacts'});
86
96
 
87
97
  // Facts about the Kilotest tool collection, included in every response.
88
- const toolsFactsSchema = z.object({
98
+ export const toolsFactsSchema = z.object({
89
99
  name: z.string(),
90
100
  description: z.object({
91
101
  'what Kilotest does': z.string(),
@@ -98,8 +108,10 @@ const toolsFactsSchema = z.object({
98
108
  'how to generate more findings': z.object({
99
109
  'new testing': z.string(),
100
110
  'retesting': z.string(),
111
+ 'approval': z.string(),
101
112
  'latency': z.string(),
102
- 'confirmation': z.string()
113
+ 'request confirmation': z.string(),
114
+ 'order confirmation': z.string()
103
115
  })
104
116
  }).describe('Explanation of what Kilotest does and how to use its tools.'),
105
117
  URL: z.string().describe('URL of the Kilotest MCP server.'),
@@ -278,7 +290,7 @@ export const listViolatorsResponseSchema = envelope('GET', z.object({
278
290
  'tag name': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
279
291
  'inner text': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
280
292
  'count of rule engines reporting that the element exhibited the issue': z.number(),
281
- 'how to get details about the element': z.object({URL: z.string(), 'request method': z.literal('GET')}),
293
+ 'how to get details about the element': z.object({method: z.literal('GET'), URL: z.string()}),
282
294
  'web users can get details about the element at': z.string()
283
295
  })).nullable()
284
296
  }));
@@ -297,10 +309,20 @@ export const listDiagnosesResponseSchema = envelope('GET', z.object({
297
309
  'x, y, width, and height of bounding box': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.')
298
310
  }).nullable(),
299
311
  'diagnoses of how the element exhibited the issue': z.array(z.object({
312
+ 'identifier of the rule engine': z.unknown().describe('Copied from the report act (which); type not guaranteed.'),
300
313
  'identifier of the violated rule': z.unknown().describe('Copied from the report instance (ruleID); type not guaranteed.'),
301
314
  'description of the violation': z.unknown().describe('Copied from the report instance (what); type not guaranteed.'),
302
315
  'severity of the violation on a 0-to-3 scale': z.unknown().describe('Copied from the report instance (ordinalSeverity); type not guaranteed.'),
303
- 'count of violations of the rule by the element': z.number()
316
+ 'count of violations of the rule by the element': z.number(),
317
+ 'how to report that you disagree with this diagnosis': z.object({
318
+ method: z.literal('POST'),
319
+ URL: z.string(),
320
+ 'request body': z.object({
321
+ engineID: z.string(),
322
+ ruleID: z.string(),
323
+ reason: z.string()
324
+ })
325
+ })
304
326
  })).or(z.object({error: z.string()})).nullable()
305
327
  }));
306
328
 
@@ -362,6 +384,28 @@ export const requestFeatureResponseSchema = envelope(
362
384
  z.null()
363
385
  );
364
386
 
387
+ export const reportDisagreementResponseSchema = envelope(
388
+ 'POST',
389
+ z.object({'details about your request': z.union([
390
+ z.object({error: z.string()}),
391
+ z.object({
392
+ 'date and time received': z.string(),
393
+ 'diagnosis you disagree with': z.object({
394
+ 'issue identifier': z.string(),
395
+ 'identifier of the rule engine': z.string(),
396
+ 'identifier of the violated rule': z.string()
397
+ }),
398
+ disposition: z.string()
399
+ })
400
+ ])}),
401
+ z.object({
402
+ engineID: reportDisagreementSchema.engineID,
403
+ ruleID: reportDisagreementSchema.ruleID,
404
+ reason: reportDisagreementSchema.reason
405
+ }),
406
+ z.null()
407
+ );
408
+
365
409
  // Identifies the report a successfully ordered or awaited job will produce, once complete.
366
410
  const reportIdentifierSchema = z.string().describe('Identifier of the report, to pass to listIssues once the report is ready (example: 260503T0432-x9z).');
367
411
 
package/api/util.test.ts CHANGED
@@ -17,6 +17,7 @@ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_A
17
17
  }
18
18
 
19
19
  import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts, getRequestFailureReason} from './util.ts';
20
+ import {toolsFactsSchema} from './schemas.ts';
20
21
  import {processTestRequest} from '../util.ts';
21
22
 
22
23
  // SETUP
@@ -137,6 +138,14 @@ test('getToolsFacts returns facts about the Kilotest tool collection', () => {
137
138
  assert.ok(facts.description['how to generate more findings']);
138
139
  });
139
140
 
141
+ test('getToolsFacts output conforms to toolsFactsSchema', () => {
142
+ // parse() catches missing or mistyped keys, and comparing the parsed result to
143
+ // the original catches extra keys that zod strips but that the strict JSON
144
+ // schema advertised to MCP clients (additionalProperties: false) rejects.
145
+ const facts = getToolsFacts();
146
+ assert.deepEqual(toolsFactsSchema.parse(facts), facts);
147
+ });
148
+
140
149
  test('getRequestFailureReason returns the URL-length reason for a urlLength result', () => {
141
150
  // processTestRequest can return urlLength for a new-test request whose URL is
142
151
  // outside the 12-to-300-character bound. No current caller reaches this
@@ -16,11 +16,13 @@ import {requestHandler} from './index.ts';
16
16
 
17
17
  const port = 3998;
18
18
  const featureRequestsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/featureRequests.json');
19
+ const disagreementsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/disagreements.json');
19
20
 
20
21
  // SETUP AND TEARDOWN
21
22
 
22
23
  const savedDBDir = process.env.DB_DIR;
23
24
  const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
25
+ const savedDisagreementsPath = process.env.DISAGREEMENTS_PATH;
24
26
  let server: http.Server;
25
27
 
26
28
  before(async () => {
@@ -31,6 +33,9 @@ before(async () => {
31
33
  process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
32
34
  await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
33
35
  await fs.writeFile(featureRequestsPath, '[]\n');
36
+ // The same for diagnosis disagreements.
37
+ process.env.DISAGREEMENTS_PATH = disagreementsPath;
38
+ await fs.writeFile(disagreementsPath, '[]\n');
34
39
  server = http.createServer(requestHandler);
35
40
  await new Promise<void>(resolve => server.listen(port, () => resolve()));
36
41
  });
@@ -48,6 +53,12 @@ after(async () => {
48
53
  else {
49
54
  delete process.env.FEATURE_REQUESTS_PATH;
50
55
  }
56
+ if (savedDisagreementsPath !== undefined) {
57
+ process.env.DISAGREEMENTS_PATH = savedDisagreementsPath;
58
+ }
59
+ else {
60
+ delete process.env.DISAGREEMENTS_PATH;
61
+ }
51
62
  await fs.rm(path.dirname(featureRequestsPath), {recursive: true, force: true});
52
63
  server.closeAllConnections?.();
53
64
  await new Promise<void>(resolve => {
@@ -106,3 +117,14 @@ test('POST /api/requestFeature parses a JSON body and returns a JSON response th
106
117
  assert.equal(details.error, undefined);
107
118
  assert.equal(details.disposition, 'received and logged; manager notified');
108
119
  });
120
+
121
+ test('POST /api/reportDisagreement parses path parameters and a JSON body and returns a JSON response through the HTTP routing layer', async () => {
122
+ const {body} = await request('POST', '/api/reportDisagreement/linkNoText/260101T0000-mix/0', {
123
+ engineID: 'axe',
124
+ ruleID: 'r11',
125
+ reason: 'The element does have an accessible name'
126
+ });
127
+ const details = body['response content']['details about your request'];
128
+ assert.equal(details.error, undefined);
129
+ assert.equal(details.disposition, 'received and logged; manager notified');
130
+ });
package/docs/SERVICE.md CHANGED
@@ -409,9 +409,11 @@ Alerts are sent to the maintainer in the following conditions:
409
409
 
410
410
  7. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request, and the request is also appended to `db/featureRequests.json`, the same way tutorial comments are appended to their own files (see item 1), so that a manager can review or delete it later via `/deleteNotesForm.html`.
411
411
 
412
- 8. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
412
+ 8. **Diagnosis disagreement received**: When a user reports disagreement with a diagnosis via the API’s or MCP server’s `reportDisagreement` operation, an alert is sent with subject `"Kilotest: diagnosis disagreement received"`, including the reason, the disputed diagnosis (rule engine, rule, issue, element, and report), and the disagreement is also appended to `db/disagreements.json`, so that a manager can review it and, when warranted, propose a correction to the `testaro-issues` classification (see item 3 for how such corrections take effect).
413
413
 
414
- 9. **AI service balance low**: When Kilotest detects that the account balance for an AI service account (such as Anthropic) has fallen below a threshold, an alert is sent with subject `"Kilotest: AI service [N] balance low"`, indicating the approximate remaining balance in dollars and the estimated cost per job.
414
+ 9. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
415
+
416
+ 10. **AI service balance low**: When Kilotest detects that the account balance for an AI service account (such as Anthropic) has fallen below a threshold, an alert is sent with subject `"Kilotest: AI service [N] balance low"`, indicating the approximate remaining balance in dollars and the estimated cost per job.
415
417
 
416
418
  ### Alert configuration
417
419
 
@@ -427,7 +429,7 @@ If any of these five variables is missing or empty at alert time, the alert is n
427
429
 
428
430
  ### Inbox size limits
429
431
 
430
- Four submission types (test requests and retest requests together, web tutorial comments, AI tutorial comments, and MCP feature requests) are each held in a pending "inbox" (`db/jobs/testRequests.json`, `db/comments/tutorialWeb.json`, `db/comments/tutorialAI.json`, and `db/featureRequests.json` respectively) awaiting manual review by the maintainer. Each inbox has a maximum size; once reached, a new, distinct submission is rejected (with a message suggesting the requester instead post to the [GitHub issues page](https://github.com/jrpool/kilotest/issues) or email `info@kilotest.com`) rather than added, so neither the inbox file nor the manager alert emails it generates can grow without bound (see GitHub issue #3, abuse type 3, repeated/automated submissions). This is modeled on a full mailbox rejecting new mail, rather than on a submission rate limit: it is the backlog size itself, not how quickly it grows, that costs the maintainer review time and risks unbounded storage. Every alert sent for an accepted submission reports the resulting inbox count against its cap, so a maintainer returning after an absence can immediately see how close an inbox is to full.
432
+ Five submission types (test requests and retest requests together, web tutorial comments, AI tutorial comments, MCP feature requests, and diagnosis disagreements) are each held in a pending "inbox" (`db/jobs/testRequests.json`, `db/comments/tutorialWeb.json`, `db/comments/tutorialAI.json`, `db/featureRequests.json`, and `db/disagreements.json` respectively) awaiting manual review by the maintainer. Each inbox has a maximum size; once reached, a new, distinct submission is rejected (with a message suggesting the requester instead post to the [GitHub issues page](https://github.com/jrpool/kilotest/issues) or email `info@kilotest.com`) rather than added, so neither the inbox file nor the manager alert emails it generates can grow without bound (see GitHub issue #3, abuse type 3, repeated/automated submissions). This is modeled on a full mailbox rejecting new mail, rather than on a submission rate limit: it is the backlog size itself, not how quickly it grows, that costs the maintainer review time and risks unbounded storage. Every alert sent for an accepted submission reports the resulting inbox count against its cap, so a maintainer returning after an absence can immediately see how close an inbox is to full.
431
433
 
432
434
  Each cap defaults to 20 and can be set independently via an environment variable:
433
435
 
@@ -435,6 +437,7 @@ Each cap defaults to 20 and can be set independently via an environment variable
435
437
  2. **`TUTORIAL_WEB_COMMENTS_MAX`**: The maximum number of comments held on the web tutorial page (`/tutorialWeb.html`).
436
438
  3. **`TUTORIAL_AI_COMMENTS_MAX`**: The maximum number of comments held on the AI agent tutorial page (`/tutorialAI.html`).
437
439
  4. **`FEATURE_REQUESTS_MAX`**: The maximum number of feature requests held via the MCP server's `requestFeature` tool.
440
+ 5. **`DISAGREEMENTS_MAX`**: The maximum number of disagreements with diagnoses held via the API’s or MCP server’s `reportDisagreement` operation.
438
441
 
439
442
  Each variable, if set, must be a non-negative integer; if unset, empty, or not a non-negative integer, the default of 20 is used instead. A value of `0` means no limit: that inbox is never treated as full, regardless of size. Unlike the alert-configuration variables above, these are not required: an unset value never disables the limit, it only selects the default size.
440
443
 
package/env.example CHANGED
@@ -35,6 +35,8 @@ TUTORIAL_WEB_COMMENTS_PATH=__placeholder__
35
35
  TUTORIAL_AI_COMMENTS_PATH=__placeholder__
36
36
  # Path of the feature requests file (defaults to db/featureRequests.json).
37
37
  FEATURE_REQUESTS_PATH=__placeholder__
38
+ # Path of the diagnosis disagreements file (defaults to db/disagreements.json).
39
+ DISAGREEMENTS_PATH=__placeholder__
38
40
 
39
41
  # Inbox size limits: each defaults to 20 if unset, empty, or not a non-negative integer.
40
42
  # A value of 0 means no limit.
@@ -47,6 +49,9 @@ TUTORIAL_WEB_COMMENTS_MAX=__placeholder__
47
49
  TUTORIAL_AI_COMMENTS_MAX=__placeholder__
48
50
  # Maximum number of feature requests held via the MCP server's requestFeature tool.
49
51
  FEATURE_REQUESTS_MAX=__placeholder__
52
+ # Maximum number of disagreements with diagnoses held via the MCP server's
53
+ # reportDisagreement tool.
54
+ DISAGREEMENTS_MAX=__placeholder__
50
55
  # Maximum number of jobs allowed in the job queue (db/jobs/queue) at once. Shared by every
51
56
  # path that enqueues a job: manager approval of a pending test/retest request, and the
52
57
  # orderNewTest/orderRetest MCP tools, which enqueue directly.
package/index.test.ts CHANGED
@@ -2334,6 +2334,50 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000
2334
2334
  }
2335
2335
  });
2336
2336
 
2337
+ test('startServer throws when THIS_KILOTEST_HOST is unset', async () => {
2338
+ const savedHost = process.env.THIS_KILOTEST_HOST;
2339
+ const savedProtocol = process.env.PROTOCOL;
2340
+ delete process.env.THIS_KILOTEST_HOST;
2341
+ delete process.env.PROTOCOL;
2342
+ try {
2343
+ await assert.rejects(startServer(), /THIS_KILOTEST_HOST/);
2344
+ }
2345
+ finally {
2346
+ if (savedHost !== undefined) {
2347
+ process.env.THIS_KILOTEST_HOST = savedHost;
2348
+ }
2349
+ if (savedProtocol !== undefined) {
2350
+ process.env.PROTOCOL = savedProtocol;
2351
+ }
2352
+ }
2353
+ });
2354
+
2355
+ test('startServer throws when PROTOCOL is https and KEY and CERT are unset', async () => {
2356
+ const savedProtocol = process.env.PROTOCOL;
2357
+ const savedKey = process.env.KEY;
2358
+ const savedCert = process.env.CERT;
2359
+ process.env.PROTOCOL = 'https';
2360
+ delete process.env.KEY;
2361
+ delete process.env.CERT;
2362
+ try {
2363
+ await assert.rejects(startServer(), /KEY.*CERT|CERT.*KEY/);
2364
+ }
2365
+ finally {
2366
+ if (savedProtocol !== undefined) {
2367
+ process.env.PROTOCOL = savedProtocol;
2368
+ }
2369
+ else {
2370
+ delete process.env.PROTOCOL;
2371
+ }
2372
+ if (savedKey !== undefined) {
2373
+ process.env.KEY = savedKey;
2374
+ }
2375
+ if (savedCert !== undefined) {
2376
+ process.env.CERT = savedCert;
2377
+ }
2378
+ }
2379
+ });
2380
+
2337
2381
  test('runIfMain calls startServer when mainModule matches currentModule', async () => {
2338
2382
  let called = false;
2339
2383
  const starter = async () => {
@@ -2358,3 +2402,25 @@ test('runIfMain does not call startServer when mainModule differs from currentMo
2358
2402
  await new Promise<void>(resolve => setImmediate(resolve));
2359
2403
  assert.equal(called, false);
2360
2404
  });
2405
+
2406
+ test('runIfMain logs the error and sets a failing exit code when the starter rejects', async () => {
2407
+ const savedExitCode = process.exitCode;
2408
+ const logged: string[] = [];
2409
+ const originalLog = console.log;
2410
+ console.log = (line: string) => logged.push(line);
2411
+ const starter = async () => {
2412
+ throw new Error('injected startup failure');
2413
+ };
2414
+ const fakeModule = {};
2415
+ try {
2416
+ runIfMain(fakeModule, fakeModule, starter);
2417
+ // Wait for the rejection handler's microtasks to run.
2418
+ await new Promise<void>(resolve => setImmediate(resolve));
2419
+ assert.ok(logged.some(line => line.includes('injected startup failure')));
2420
+ assert.equal(process.exitCode, 1);
2421
+ }
2422
+ finally {
2423
+ console.log = originalLog;
2424
+ process.exitCode = savedExitCode;
2425
+ }
2426
+ });