@jrpool/kilotest 59.1.3 → 60.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.
Files changed (64) hide show
  1. package/README.md +2 -5
  2. package/api/awaitTest.test.ts +9 -9
  3. package/api/awaitTest.ts +12 -12
  4. package/api/getReport.test.ts +4 -4
  5. package/api/getReport.ts +9 -9
  6. package/api/listDiagnoses.test.ts +14 -14
  7. package/api/listDiagnoses.ts +8 -8
  8. package/api/listIssues.test.ts +15 -15
  9. package/api/listIssues.ts +18 -20
  10. package/api/listPages.test.ts +2 -2
  11. package/api/listPages.ts +4 -4
  12. package/api/listViolators.test.ts +10 -10
  13. package/api/listViolators.ts +10 -10
  14. package/api/orderNewTest.test.ts +1 -2
  15. package/api/orderNewTest.ts +3 -4
  16. package/api/orderRetest.test.ts +10 -10
  17. package/api/orderRetest.ts +13 -14
  18. package/api/requestRetest.test.ts +11 -11
  19. package/api/requestRetest.ts +11 -11
  20. package/api/routes.ts +9 -11
  21. package/api/schemas.ts +18 -28
  22. package/api/util.test.ts +2 -2
  23. package/api/util.ts +7 -7
  24. package/capability.md +4 -4
  25. package/docs/AI-TOOLS.md +45 -5
  26. package/docs/decisions/009-report-id.md +16 -0
  27. package/index.test.ts +91 -41
  28. package/index.ts +66 -15
  29. package/lhm.plugin.json +33 -119
  30. package/llms-full.txt +18 -24
  31. package/llms.txt +12 -12
  32. package/mcp.test.ts +11 -20
  33. package/mcp.ts +18 -18
  34. package/openapi-3.0.yaml +1847 -0
  35. package/openapi.yaml +38 -97
  36. package/package.json +8 -2
  37. package/routing.test.ts +1 -1
  38. package/scripts/generate-openapi.ts +83 -1
  39. package/scripts/postPR.ts +89 -0
  40. package/scripts/prePR.ts +64 -0
  41. package/server.json +1 -1
  42. package/util.test.ts +32 -33
  43. package/util.ts +47 -43
  44. package/web/hideReportForm/index.ts +5 -6
  45. package/web/listDiagnoses/index.ts +4 -5
  46. package/web/listDiagnoses/listDiagnoses.test.ts +13 -13
  47. package/web/listIssues/index.ts +11 -12
  48. package/web/listIssues/listIssues.test.ts +24 -24
  49. package/web/listPages/index.ts +2 -2
  50. package/web/listPages/listPages.test.ts +2 -2
  51. package/web/listTopIssues/index.ts +2 -2
  52. package/web/listViolators/index.ts +5 -6
  53. package/web/listViolators/listViolators.test.ts +12 -12
  54. package/web/reannotate/index.ts +3 -3
  55. package/web/reannotateForm/index.ts +1 -2
  56. package/web/reportDeletion.ts +9 -12
  57. package/web/requestRetest/index.ts +2 -2
  58. package/web/requestRetest/requestRetest.test.ts +7 -7
  59. package/web/requestRetestForm/index.html +2 -2
  60. package/web/requestRetestForm/index.ts +3 -4
  61. package/web/requestRetestForm/requestRetestForm.test.ts +7 -8
  62. package/web/unhideReportForm/index.ts +4 -6
  63. package/web/util.test.ts +29 -29
  64. package/web/util.ts +1 -1
@@ -10,9 +10,9 @@ import {populateTemplate, processTestRequest} from '../../util.ts';
10
10
  // FUNCTIONS
11
11
 
12
12
  export const answer = async (pageArgs: string, reason: string) => {
13
- const [timeStamp, jobID] = pageArgs.split('/') as [string, string];
13
+ const reportID = pageArgs;
14
14
  // Process the request. This resolves the cited report's description and URL itself.
15
- const processResult = await processTestRequest(reason, {timeStamp, jobID});
15
+ const processResult = await processTestRequest(reason, {reportID});
16
16
  // If the cited report does not exist:
17
17
  if (processResult.result === 'nonreport') {
18
18
  // Report this.
@@ -65,7 +65,7 @@ test('answer returns ok with a populated answer page for a valid retest request'
65
65
  // Require the module after DB_DIR is set.
66
66
  const {answer} = await import('./index.ts');
67
67
  // Use the 260101T0001-ct report (All CantTell Page, https://example.com/canttell).
68
- const result: any = await answer('260101T0001/ct', 'Because changes were made');
68
+ const result: any = await answer('260101T0001-ct', 'Because changes were made');
69
69
  assert.equal(result.status, 'ok');
70
70
  assert.ok(result.answerPage, 'answerPage should be present');
71
71
  // Verify the description and reason placeholders were replaced with the report target.
@@ -86,7 +86,7 @@ test('answer returns ok with a populated answer page for a valid retest request'
86
86
  test('answer returns an error when the report does not exist', async () => {
87
87
  const {answer} = await import('./index.ts');
88
88
  // Use a nonexistent report timestamp and jobID.
89
- const result: any = await answer('990101T0000/xxx', 'Because changes were made');
89
+ const result: any = await answer('990101T0000-xxx', 'Because changes were made');
90
90
  assert.equal(result.status, 'error');
91
91
  assert.equal(result.message, 'Invalid request');
92
92
  });
@@ -94,8 +94,8 @@ test('answer returns an error when the report does not exist', async () => {
94
94
  test('answer returns an error for a duplicate retest request', async () => {
95
95
  const {answer} = await import('./index.ts');
96
96
  // Submit the same retest request twice; the second is a duplicate.
97
- await answer('260101T0001/ct', 'Because changes were made');
98
- const result: any = await answer('260101T0001/ct', 'Because changes were made');
97
+ await answer('260101T0001-ct', 'Because changes were made');
98
+ const result: any = await answer('260101T0001-ct', 'Because changes were made');
99
99
  assert.equal(result.status, 'error');
100
100
  assert.equal(result.message, 'Test request duplicates an already submitted request');
101
101
  });
@@ -103,7 +103,7 @@ test('answer returns an error for a duplicate retest request', async () => {
103
103
  test('answer returns an error when the cited report has been superseded', async () => {
104
104
  const {answer} = await import('./index.ts');
105
105
  // 260101T0000-mix (Mixed Outcomes) is superseded by 260202T0000-new.
106
- const result: any = await answer('260101T0000/mix', 'Because changes were made');
106
+ const result: any = await answer('260101T0000-mix', 'Because changes were made');
107
107
  assert.equal(result.status, 'error');
108
108
  assert.equal(result.message, 'A later report about the page is already available');
109
109
  });
@@ -119,7 +119,7 @@ test('answer returns an error when a matching job is already claimed or queued',
119
119
  target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
120
120
  }));
121
121
  try {
122
- const result: any = await answer('260101T0001/ct', 'Because changes were made');
122
+ const result: any = await answer('260101T0001-ct', 'Because changes were made');
123
123
  assert.equal(result.status, 'error');
124
124
  assert.equal(result.message, 'A request to test a page with the same description or URL is already approved');
125
125
  }
@@ -135,7 +135,7 @@ test('answer returns an error with a fallback channel when the request queue is
135
135
  timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Because filler'
136
136
  }));
137
137
  await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
138
- const result: any = await answer('260101T0001/ct', 'Because changes were made');
138
+ const result: any = await answer('260101T0001-ct', 'Because changes were made');
139
139
  assert.equal(result.status, 'error');
140
140
  assert.ok(result.message.startsWith('Too many requests are awaiting approval right now.'));
141
141
  assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
@@ -13,9 +13,9 @@
13
13
  <body>
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Retest recommendation for __target__</h1>
16
- <p>The __target__ page was <a href="/listIssues.html/__timeStamp__/__jobID__">last tested</a> __ago__ ago by job <code>__jobID__</code> on __dateTime__.</p>
16
+ <p>The __target__ page was <a href="/listIssues.html/__reportID__">last tested</a> __ago__ ago by job <code>__reportID__</code> on __dateTime__.</p>
17
17
  <p>To recommend a retest, submit the following form.</p>
18
- <form action="/requestRetest.html/__timeStamp__/__jobID__" method="post">
18
+ <form action="/requestRetest.html/__reportID__" method="post">
19
19
  <p><label>
20
20
  Why retest the page (answer required)?<br>
21
21
  <input size="70" minlength="5" maxlength="100" name="why" required>
@@ -12,12 +12,12 @@ import {getAgoString, getDateTimeString} from '../util.ts';
12
12
 
13
13
  // Returns a retest recommendation form.
14
14
  export const answer = async (pageArgs: string) => {
15
- const [timeStamp, jobID] = pageArgs.split('/') as [string, string];
15
+ const reportID = pageArgs;
16
16
  // Get data on the latest available reports.
17
17
  const reportExtracts = await getReportExtracts(true);
18
18
  // Get data on the report whose page is to be retested.
19
19
  const reportExtract = reportExtracts.find(
20
- (reportExtract: any) => reportExtract.timeStamp === timeStamp && reportExtract.jobID === jobID
20
+ (reportExtract: any) => reportExtract.id === reportID
21
21
  );
22
22
  // Initialize the page description.
23
23
  let target: string;
@@ -35,8 +35,7 @@ export const answer = async (pageArgs: string) => {
35
35
  const reportTime = new Date(reportExtract?.reportTime ?? NaN);
36
36
  const query: Record<string, string> = {
37
37
  target,
38
- timeStamp,
39
- jobID,
38
+ reportID,
40
39
  ago: getAgoString(reportTime),
41
40
  dateTime: getDateTimeString(reportTime)
42
41
  };
@@ -30,7 +30,7 @@ after(() => {
30
30
  // TESTS
31
31
 
32
32
  test('requestRetestForm returns ok with the page name for a valid report', async () => {
33
- const result = await answer('260202T0000/new');
33
+ const result = await answer('260202T0000-new');
34
34
  assert.equal(result.status, 'ok');
35
35
  assert.ok(result.answerPage);
36
36
  const html = parse(result.answerPage);
@@ -38,11 +38,11 @@ test('requestRetestForm returns ok with the page name for a valid report', async
38
38
  assert.ok(title?.includes('Mixed Outcomes'));
39
39
  });
40
40
 
41
- test('requestRetestForm populates the form action with the timeStamp and jobID', async () => {
42
- const result = await answer('260202T0000/new');
41
+ test('requestRetestForm populates the form action with the reportID', async () => {
42
+ const result = await answer('260202T0000-new');
43
43
  const html = parse(result.answerPage);
44
44
  const form = html.querySelector('form');
45
- assert.equal(form?.getAttribute('action'), '/requestRetest.html/260202T0000/new');
45
+ assert.equal(form?.getAttribute('action'), '/requestRetest.html/260202T0000-new');
46
46
  });
47
47
 
48
48
  test('requestRetestForm shows the fallback message for a nonexistent report', async () => {
@@ -52,7 +52,7 @@ test('requestRetestForm shows the fallback message for a nonexistent report', as
52
52
  });
53
53
 
54
54
  test('requestRetestForm dates the report by its completion time', async () => {
55
- const result = await answer('260202T0000/new');
55
+ const result = await answer('260202T0000-new');
56
56
  assert.ok(result.answerPage.includes('on 2026-02-02 at 00:10'));
57
57
  });
58
58
 
@@ -63,10 +63,9 @@ test('requestRetestForm describes the time of a nonexistent report as unknown',
63
63
  });
64
64
 
65
65
  test('requestRetestForm includes the ago and dateTime placeholders replaced', async () => {
66
- const result = await answer('260202T0000/new');
66
+ const result = await answer('260202T0000-new');
67
67
  assert.ok(!result.answerPage.includes('__ago__'));
68
68
  assert.ok(!result.answerPage.includes('__dateTime__'));
69
69
  assert.ok(!result.answerPage.includes('__target__'));
70
- assert.ok(!result.answerPage.includes('__timeStamp__'));
71
- assert.ok(!result.answerPage.includes('__jobID__'));
70
+ assert.ok(!result.answerPage.includes('__reportID__'));
72
71
  });
@@ -59,13 +59,11 @@ export const answer = async (_: any, search: string, method: string) => {
59
59
  // Get the report.
60
60
  const report = JSON.parse(reportJSON);
61
61
  const {id, target} = report;
62
- const [timeStamp, jobID] = id.split('-');
63
62
  const {what: description} = target;
64
63
  // Add its data to the array.
65
64
  reportsData.push({
66
65
  description,
67
- timeStamp,
68
- jobID,
66
+ id,
69
67
  reportTime: getReportEndTime(report)
70
68
  });
71
69
  }
@@ -80,12 +78,12 @@ export const answer = async (_: any, search: string, method: string) => {
80
78
  const margin = ' '.repeat(12);
81
79
  // For each report:
82
80
  reportsData.forEach(data => {
83
- const {jobID, timeStamp, description, reportTime} = data;
81
+ const {id, description, reportTime} = data;
84
82
  const when = getDateTimeString(reportTime);
85
- const specString = `${description} (job <code>${timeStamp}-${jobID}</code> completed ${when})`;
83
+ const specString = `${description} (job <code>${id}</code> completed ${when})`;
86
84
  // Add a line with a radio button to unhide it.
87
85
  lines.push(
88
- `${margin}<p><input type="radio" name="report" value="${timeStamp}-${jobID}"> ${specString}</p>`
86
+ `${margin}<p><input type="radio" name="report" value="${id}"> ${specString}</p>`
89
87
  );
90
88
  });
91
89
  const query: Record<string, string> = {
package/web/util.test.ts CHANGED
@@ -119,8 +119,8 @@ test('makeBreakable inserts wbr before non-initial slashes', () => {
119
119
  });
120
120
 
121
121
  // Returns a fixture report, failing the test if it is not usable.
122
- const getFixtureReport = async (timeStamp: string, jobID: string) => {
123
- const report = await getReport(timeStamp, jobID);
122
+ const getFixtureReport = async (reportID: string) => {
123
+ const report = await getReport(reportID);
124
124
  if (isReportError(report)) {
125
125
  assert.fail(report.error);
126
126
  }
@@ -128,19 +128,19 @@ const getFixtureReport = async (timeStamp: string, jobID: string) => {
128
128
  };
129
129
 
130
130
  test('getPageData returns page data for a valid report', async () => {
131
- const data = getPageData(await getFixtureReport('260101T0000', 'mix'));
131
+ const data = getPageData(await getFixtureReport('260101T0000-mix'));
132
132
  assert.equal(data.description, 'Mixed Outcomes');
133
133
  assert.equal(data.url, 'https://example.com/mixed');
134
134
  assert.equal(typeof data.daysAgo, 'number');
135
135
  });
136
136
 
137
137
  test('getReport returns an error for a nonexistent report', async () => {
138
- const report = await getReport('999999T9999', 'xxx');
138
+ const report = await getReport('999999T9999-xxx');
139
139
  assert.ok(isReportError(report));
140
140
  });
141
141
 
142
142
  test('getPageDataStrings returns HTML strings for a valid report', async () => {
143
- const strings = getPageDataStrings(await getFixtureReport('260101T0000', 'mix'));
143
+ const strings = getPageDataStrings(await getFixtureReport('260101T0000-mix'));
144
144
  assert.equal(strings.description, 'Mixed Outcomes');
145
145
  assert.equal(strings.url, 'https://example.com/mixed');
146
146
  assert.equal(strings.urlLink, '<a href="https://example.com/mixed">https://example.com/mixed</a>');
@@ -149,7 +149,7 @@ test('getPageDataStrings returns HTML strings for a valid report', async () => {
149
149
  });
150
150
 
151
151
  test('getPageDataStrings dates a report by its completion time, not by its job time stamp', async () => {
152
- const report = await getFixtureReport('260101T0001', 'ct');
152
+ const report = await getFixtureReport('260101T0001-ct');
153
153
  report.jobData.endTime = '26-03-04T05:06';
154
154
  const strings = getPageDataStrings(report);
155
155
  assert.equal(strings.description, 'All CantTell Page');
@@ -167,7 +167,7 @@ test('getDateTimeString describes an invalid date as unknown', () => {
167
167
  });
168
168
 
169
169
  test('getPageDataStrings derives the strings from the report object', async () => {
170
- const report = await getFixtureReport('260101T0000', 'mix');
170
+ const report = await getFixtureReport('260101T0000-mix');
171
171
  report.target.what = 'Custom Page';
172
172
  const strings = getPageDataStrings(report);
173
173
  assert.equal(strings.description, 'Custom Page');
@@ -188,7 +188,7 @@ test('getPageDataStrings reports a 1-day-old report as tested 1 day ago', () =>
188
188
 
189
189
  test('getReportData returns data for a valid report', async () => {
190
190
  const {getReportData} = await import('./util.ts');
191
- const result = getReportData(await getFixtureReport('260101T0000', 'mix'));
191
+ const result = getReportData(await getFixtureReport('260101T0000-mix'));
192
192
  assert.equal(result.url, 'https://example.com/mixed');
193
193
  assert.equal(result.jobName, '260101T0000-mix');
194
194
  });
@@ -201,7 +201,7 @@ test('getReportData falls back to the engine ID for an unknown prevented engine'
201
201
  const reportPath = path.join(reportsPath(), '260103T0000-unk.json');
202
202
  await fs.writeFile(reportPath, JSON.stringify(reportJSON));
203
203
  try {
204
- const result = getReportData(await getFixtureReport('260103T0000', 'unk'));
204
+ const result = getReportData(await getFixtureReport('260103T0000-unk'));
205
205
  assert.ok(result.preventedEngineNames.includes('unknownEngine'));
206
206
  }
207
207
  finally {
@@ -211,7 +211,7 @@ test('getReportData falls back to the engine ID for an unknown prevented engine'
211
211
 
212
212
  test('getPageFactsLines returns the about-page list lines for a report', async () => {
213
213
  const {getReportData} = await import('./util.ts');
214
- const report = await getFixtureReport('260101T0000', 'mix');
214
+ const report = await getFixtureReport('260101T0000-mix');
215
215
  const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), ' ');
216
216
  const html = lines.join('\n');
217
217
  assert.ok(html.includes('<li>URL: <a href="https://example.com/mixed">'));
@@ -223,19 +223,19 @@ test('getPageFactsLines returns the about-page list lines for a report', async (
223
223
 
224
224
  test('getPageFactsLines ends with a link to download the full report', async () => {
225
225
  const {getReportData} = await import('./util.ts');
226
- const report = await getFixtureReport('260101T0000', 'mix');
226
+ const report = await getFixtureReport('260101T0000-mix');
227
227
  const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), '');
228
228
  const topItems = parse(lines.join('\n')).querySelectorAll('ul:not(ul ul) > li');
229
229
  const lastItem = topItems[topItems.length - 1]!;
230
230
  assert.ok(topItems[topItems.length - 2]!.text.trim().startsWith('Summary of results:'));
231
- assert.equal(lastItem.querySelector('a')?.getAttribute('href'), '/fullReport.json/260101T0000/mix');
231
+ assert.equal(lastItem.querySelector('a')?.getAttribute('href'), '/fullReport.json/260101T0000-mix');
232
232
  assert.equal(lastItem.text.trim(), 'Download the full technical report (JSON)');
233
233
  });
234
234
 
235
235
  // Returns the 3 rule-engine amounts and the 3 summary amounts of the page facts of a report.
236
- const getResultFacts = async (timeStamp: string, jobID: string) => {
236
+ const getResultFacts = async (reportID: string) => {
237
237
  const {getReportData} = await import('./util.ts');
238
- const report = await getFixtureReport(timeStamp, jobID);
238
+ const report = await getFixtureReport(reportID);
239
239
  const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), '');
240
240
  const items = parse(lines.join('\n')).querySelectorAll('li');
241
241
  const itemTexts: string[] = items.map(li => li.text.trim());
@@ -263,21 +263,21 @@ const getResultFacts = async (timeStamp: string, jobID: string) => {
263
263
  };
264
264
 
265
265
  // Expected facts, hand-computed from the fixtures.
266
- const expectedFacts: [string, string, number, number, number, number, number, number][] = [
267
- // Time stamp, job ID, engines called, engines able to test, reporting engines, violations, violators, issues.
268
- ['260101T0000', 'mix', 2, 2, 2, 3, 2, 2],
269
- ['260202T0000', 'new', 1, 1, 1, 1, 1, 1],
270
- ['260101T0001', 'ct', 1, 1, 0, 0, 0, 0],
271
- ['260101T0002', 'no', 1, 1, 1, 1, 1, 1],
272
- ['260101T0005', 'emp', 1, 1, 0, 0, 0, 0],
273
- ['260101T0006', 'prv', 2, 1, 1, 1, 1, 1],
274
- ['260101T0008', 'mul', 2, 2, 2, 4, 3, 1],
275
- ['260101T0009', 'brd', 3, 3, 1, 4, 4, 1]
266
+ const expectedFacts: [string, number, number, number, number, number, number][] = [
267
+ // Report ID, engines called, engines able to test, reporting engines, violations, violators, issues.
268
+ ['260101T0000-mix', 2, 2, 2, 3, 2, 2],
269
+ ['260202T0000-new', 1, 1, 1, 1, 1, 1],
270
+ ['260101T0001-ct', 1, 1, 0, 0, 0, 0],
271
+ ['260101T0002-no', 1, 1, 1, 1, 1, 1],
272
+ ['260101T0005-emp', 1, 1, 0, 0, 0, 0],
273
+ ['260101T0006-prv', 2, 1, 1, 1, 1, 1],
274
+ ['260101T0008-mul', 2, 2, 2, 4, 3, 1],
275
+ ['260101T0009-brd', 3, 3, 1, 4, 4, 1]
276
276
  ];
277
277
 
278
- for (const [timeStamp, jobID, called, tested, reporters, violations, violators, issues] of expectedFacts) {
279
- test(`getPageFactsLines gives correct rule-engine and summary amounts for ${timeStamp}-${jobID}`, async () => {
280
- const facts = await getResultFacts(timeStamp, jobID);
278
+ for (const [reportID, called, tested, reporters, violations, violators, issues] of expectedFacts) {
279
+ test(`getPageFactsLines gives correct rule-engine and summary amounts for ${reportID}`, async () => {
280
+ const facts = await getResultFacts(reportID);
281
281
  assert.equal(facts.called, called);
282
282
  assert.equal(facts.tested, tested);
283
283
  assert.equal(facts.reporters, reporters);
@@ -290,7 +290,7 @@ for (const [timeStamp, jobID, called, tested, reporters, violations, violators,
290
290
 
291
291
  test('getIssueFactsLines returns the about-issue list lines for a report', async () => {
292
292
  const {getIssueFactsLines} = await import('./util.ts');
293
- const report = await getFixtureReport('260101T0000', 'mix');
293
+ const report = await getFixtureReport('260101T0000-mix');
294
294
  const lines = getIssueFactsLines(report, 'linkNoText', ' ');
295
295
  const html = lines.join('\n');
296
296
  assert.ok(html.includes('<li>Why it matters:'));
@@ -303,7 +303,7 @@ test('getIssueFactsLines returns the about-issue list lines for a report', async
303
303
 
304
304
  test('getViolatorFactsLines returns the about-violator list lines for a report', async () => {
305
305
  const {getViolatorFactsLines} = await import('./util.ts');
306
- const report = await getFixtureReport('260101T0000', 'mix');
306
+ const report = await getFixtureReport('260101T0000-mix');
307
307
  const lines = getViolatorFactsLines(report, 'linkNoText', '0', null, ' ');
308
308
  const html = lines.join('\n');
309
309
  assert.ok(html.includes('<li>Tag name: <code>'));
package/web/util.ts CHANGED
@@ -271,7 +271,7 @@ export const getPageDataStrings = (report: UsableReport): PageDataStrings => {
271
271
  url,
272
272
  urlLink: `<a href="${url}">${url}</a>`,
273
273
  testInfo: `Tested ${daysAgo === 1 ? '1 day' : `${daysAgo} days`} ago on ${when} by job <code>${report.id}</code>`,
274
- fullReportLink: `<a href="/fullReport.json/${report.id.replace('-', '/')}">Download the full technical report</a> (JSON)`
274
+ fullReportLink: `<a href="/fullReport.json/${report.id}">Download the full technical report</a> (JSON)`
275
275
  };
276
276
  };
277
277
  // Returns the lines of a list of facts about the page of an available report.