|
| 1 | +import { execFile } from "node:child_process"; |
1 | 2 | import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; |
2 | | -import { join } from "node:path"; |
3 | 3 | import { tmpdir } from "node:os"; |
| 4 | +import { join, resolve } from "node:path"; |
| 5 | +import { promisify } from "node:util"; |
| 6 | + |
4 | 7 | import { describe, expect, it, beforeEach, afterEach } from "vitest"; |
5 | 8 | import { stringify } from "yaml"; |
6 | 9 |
|
7 | 10 | import { verifyRule, getSchemaPayload } from "../src/rules/verify"; |
| 11 | +import { cliRejectionToResult } from "./support/spawn-cli"; |
| 12 | + |
| 13 | +const execFileAsync = promisify(execFile); |
8 | 14 |
|
9 | 15 | /** |
10 | 16 | * A rule that fires on `eval(...)`, plus one test file holding exactly the |
@@ -744,3 +750,181 @@ describe("getSchemaPayload", () => { |
744 | 750 | ).toBe(true); |
745 | 751 | }); |
746 | 752 | }); |
| 753 | + |
| 754 | +/** |
| 755 | + * `packages/cli/src/commands/verify.ts`'s `tested`/`failed`/`refused` split — |
| 756 | + * taskless/cli#284. |
| 757 | + * |
| 758 | + * `tested = results.length - refused.length`, and both the human summary and |
| 759 | + * the `--json` `ok` field are derived from `failed`, which itself excludes |
| 760 | + * anything `isRefused`. Every other test in this repo runs a single rule per |
| 761 | + * invocation, which cannot exercise either arithmetic operation: with one |
| 762 | + * rule, `results.length - refused.length` and `results.length` agree, and a |
| 763 | + * refused rule "leaking" into `failed` is invisible unless something else in |
| 764 | + * the run is a genuine, non-refused failure to compare it against. |
| 765 | + * |
| 766 | + * These run `taskless test` (not `verify`) because `refused` is only ever set |
| 767 | + * by `testOneRule` on a runtime rule denied `--dangerously-run-scripts` — see |
| 768 | + * `packages/cli/src/rules/inspect.ts`. `verify` never produces a refusal, so |
| 769 | + * it cannot exercise this split at all. |
| 770 | + */ |
| 771 | +const verifyMixedRunBinPath = resolve(import.meta.dirname, "../dist/index.js"); |
| 772 | + |
| 773 | +async function runVerifyMixedRunCli(args: string[]) { |
| 774 | + try { |
| 775 | + const { stdout, stderr } = await execFileAsync("node", [ |
| 776 | + verifyMixedRunBinPath, |
| 777 | + ...args, |
| 778 | + ]); |
| 779 | + return { stdout, stderr, exitCode: 0 }; |
| 780 | + } catch (error) { |
| 781 | + return cliRejectionToResult(error, [verifyMixedRunBinPath, ...args]); |
| 782 | + } |
| 783 | +} |
| 784 | + |
| 785 | +describe("test: the tested/failed/refused split on a mixed run (#284)", () => { |
| 786 | + let cwd: string; |
| 787 | + |
| 788 | + /** An sg rule whose own fixtures either all pass or one deliberately fails. */ |
| 789 | + async function sgRule( |
| 790 | + id: string, |
| 791 | + options: { passes: boolean } |
| 792 | + ): Promise<void> { |
| 793 | + const directory = join(cwd, ".taskless", "rules", "sg", id); |
| 794 | + await mkdir(join(directory, ".tests"), { recursive: true }); |
| 795 | + await writeFile( |
| 796 | + join(directory, `${id}.yml`), |
| 797 | + stringify({ |
| 798 | + id, |
| 799 | + language: "TypeScript", |
| 800 | + severity: "error", |
| 801 | + message: `no ${id}`, |
| 802 | + rule: { pattern: "console.log($ARG)" }, |
| 803 | + }) |
| 804 | + ); |
| 805 | + // A "valid" fixture that actually fires the rule is what makes `ast-grep |
| 806 | + // test` — and therefore `taskless test` — report this rule as failed |
| 807 | + // rather than refused or passed. |
| 808 | + const valid = options.passes |
| 809 | + ? ["const a = 1;"] |
| 810 | + : ["console.log('this should fail');"]; |
| 811 | + await writeFile( |
| 812 | + join(directory, ".tests", `${id}-test.yml`), |
| 813 | + stringify({ id, valid, invalid: ["console.log('correct');"] }) |
| 814 | + ); |
| 815 | + } |
| 816 | + |
| 817 | + /** |
| 818 | + * A runtime rule that verifies cleanly but whose fixtures are always |
| 819 | + * REFUSED, because nothing here ever passes `--dangerously-run-scripts`. |
| 820 | + * Shape mirrors `runtime-check.test.ts`'s `RUNTIME_CAPTURE`/`RUNTIME_CHECK`. |
| 821 | + */ |
| 822 | + async function refusedRuntimeRule(id: string): Promise<void> { |
| 823 | + const directory = join(cwd, ".taskless", "rules", "runtime", id); |
| 824 | + await mkdir(join(directory, "captures"), { recursive: true }); |
| 825 | + await writeFile( |
| 826 | + join(directory, "captures", "logs.yml"), |
| 827 | + stringify({ |
| 828 | + id: "logs-abc12345", |
| 829 | + language: "typescript", |
| 830 | + rule: { pattern: "console.log($A)" }, |
| 831 | + metadata: { |
| 832 | + taskless: { |
| 833 | + version: 1, |
| 834 | + kind: "runtime", |
| 835 | + name: "logs", |
| 836 | + check: "check.ts", |
| 837 | + match: "anchor", |
| 838 | + }, |
| 839 | + }, |
| 840 | + }) |
| 841 | + ); |
| 842 | + await writeFile( |
| 843 | + join(directory, "check.ts"), |
| 844 | + "export default async function (root, matches) {\n" + |
| 845 | + ' return matches.map((m) => ({ file: m.file, line: m.line, message: "runtime " + m.rule, severity: "warning" }));\n' + |
| 846 | + "}\n" |
| 847 | + ); |
| 848 | + } |
| 849 | + |
| 850 | + beforeEach(async () => { |
| 851 | + cwd = await mkdtemp(join(tmpdir(), "tskl-verify-mixed-")); |
| 852 | + await runVerifyMixedRunCli(["init", "--no-interactive", "-d", cwd]); |
| 853 | + }); |
| 854 | + |
| 855 | + afterEach(async () => { |
| 856 | + await rm(cwd, { recursive: true, force: true }); |
| 857 | + }); |
| 858 | + |
| 859 | + it("keeps tested, failed, and refused as three independent counts", async () => { |
| 860 | + // One rule of each outcome: a pass, a genuine failure, and a refusal. |
| 861 | + // `results.length` is 3; `tested` must be 2 (3 minus the 1 refused); and |
| 862 | + // `failed` must be 1 (the genuine failure only, not the refusal too). |
| 863 | + // With all three numbers different, no pair of them can be swapped for |
| 864 | + // another and still match what this test asserts. |
| 865 | + await sgRule("pass-rule", { passes: true }); |
| 866 | + await sgRule("fail-rule", { passes: false }); |
| 867 | + await refusedRuntimeRule("rt-rule"); |
| 868 | + |
| 869 | + const jsonResult = await runVerifyMixedRunCli([ |
| 870 | + "test", |
| 871 | + "-d", |
| 872 | + cwd, |
| 873 | + "--json", |
| 874 | + ]); |
| 875 | + const report = JSON.parse(jsonResult.stdout) as { |
| 876 | + ok: boolean; |
| 877 | + rules: { |
| 878 | + engine: string; |
| 879 | + ruleId: string; |
| 880 | + ok: boolean; |
| 881 | + refused?: string; |
| 882 | + }[]; |
| 883 | + }; |
| 884 | + // The `ok` field is derived from `failed`, and must stay false because of |
| 885 | + // the one genuine failure — a mutation that let the refusal leak into |
| 886 | + // `failed` would not be visible here (it is already false), which is why |
| 887 | + // the second test below isolates that case with no genuine failure. |
| 888 | + expect(report.ok).toBe(false); |
| 889 | + expect(jsonResult.exitCode).not.toBe(0); |
| 890 | + expect(report.rules.find((rule) => rule.ruleId === "pass-rule")?.ok).toBe( |
| 891 | + true |
| 892 | + ); |
| 893 | + expect(report.rules.find((rule) => rule.ruleId === "fail-rule")?.ok).toBe( |
| 894 | + false |
| 895 | + ); |
| 896 | + const runtimeRule = report.rules.find((rule) => rule.ruleId === "rt-rule"); |
| 897 | + expect(runtimeRule?.refused).toBeDefined(); |
| 898 | + |
| 899 | + // The human summary is the only place `tested` is rendered at all — it is |
| 900 | + // not in the `--json` envelope — so this is the only way to pin its value. |
| 901 | + const humanResult = await runVerifyMixedRunCli(["test", "-d", cwd]); |
| 902 | + expect(humanResult.exitCode).not.toBe(0); |
| 903 | + expect(humanResult.stdout).toContain("1 of 2 rule(s) failed."); |
| 904 | + expect(humanResult.stdout).toContain("1 rule(s) did not run."); |
| 905 | + }); |
| 906 | + |
| 907 | + it("does not let a refused rule count as a failure", async () => { |
| 908 | + // No genuine failure at all: one passing rule and one refused rule. If a |
| 909 | + // refused rule were counted in `failed`, `ok` would read false and the |
| 910 | + // human summary would read "of 1 rule(s) failed" instead of "tested". |
| 911 | + await sgRule("pass-rule", { passes: true }); |
| 912 | + await refusedRuntimeRule("rt-rule"); |
| 913 | + |
| 914 | + const jsonResult = await runVerifyMixedRunCli([ |
| 915 | + "test", |
| 916 | + "-d", |
| 917 | + cwd, |
| 918 | + "--json", |
| 919 | + ]); |
| 920 | + const report = JSON.parse(jsonResult.stdout) as { ok: boolean }; |
| 921 | + expect(report.ok).toBe(true); |
| 922 | + expect(jsonResult.exitCode).toBe(0); |
| 923 | + |
| 924 | + const humanResult = await runVerifyMixedRunCli(["test", "-d", cwd]); |
| 925 | + expect(humanResult.exitCode).toBe(0); |
| 926 | + expect(humanResult.stdout).toContain("1 rule(s) tested."); |
| 927 | + expect(humanResult.stdout).toContain("1 rule(s) did not run."); |
| 928 | + expect(humanResult.stdout).not.toContain("failed"); |
| 929 | + }); |
| 930 | +}); |
0 commit comments