1export type Comment = { readonly path: string; readonly line: number; readonly body: string };
2
3const testStart = /^\s*(it|test|describe)(\.\w+)*\(/;
4
5/** 1-based line range of the test whose title is `title`, up to the next test or describe. */
6export const testRange = (source: string, title: string) => {
7 const lines = source.split("\n");
8 const start = lines.findIndex(
9 (line) => line.includes(`"${title}"`) || line.includes(`'${title}'`),
10 );
11 if (start < 0) return undefined;
12 const next = lines.findIndex((line, index) => index > start && testStart.test(line));
13 return { start: start + 1, end: next < 0 ? lines.length : next };
14};
15
16// Models often anchor a comment on the blank line or brace just above the test.
17const slack = 2;
18
19/**
20 * A bad case passes when a comment lands inside a target test. A guard case passes when the
21 * check posts nothing and ends with exactly `All clear`, as Macroscope requires.
22 */
23export const grade = (input: {
24 readonly kind: "bad" | "guard";
25 readonly targets: ReadonlyArray<{
26 readonly path: string;
27 readonly start: number;
28 readonly end: number;
29 }>;
30 readonly comments: ReadonlyArray<Comment>;
31 readonly final: string;
32}) => {
33 if (input.kind === "guard") {
34 if (input.comments.length > 0)
35 return { pass: false, reason: `${input.comments.length} comment(s)` };
36 if (input.final.trim() !== "All clear")
37 return { pass: false, reason: "final reply is not `All clear`" };
38 return { pass: true, reason: "all clear" };
39 }
40 const hit = input.comments.some((comment) =>
41 input.targets.some(
42 (target) =>
43 comment.path === target.path &&
44 comment.line >= target.start - slack &&
45 comment.line <= target.end,
46 ),
47 );
48 return hit
49 ? { pass: true, reason: "flagged a target" }
50 : { pass: false, reason: `missed (${input.comments.length} comment(s) elsewhere)` };
51};