@bigknoxy/hashpilot 4.6.3
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/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-edit test baselines for `verify-changes` (issue #24).
|
|
3
|
+
*
|
|
4
|
+
* The destructive case this exists to close: a repo has one already-broken
|
|
5
|
+
* test, an agent makes a correct edit, verification runs, the suite fails for a
|
|
6
|
+
* reason the agent never touched, and `--revert-on-failure` deletes correct
|
|
7
|
+
* work. Subtracting a baseline recorded *before* the edit turns that into a
|
|
8
|
+
* pass, and leaves a genuinely new failure still failing.
|
|
9
|
+
*
|
|
10
|
+
* The baseline is keyed by commit SHA because that is the granularity at which
|
|
11
|
+
* "which tests were already broken" actually changes; every edit at the same
|
|
12
|
+
* commit reuses one recorded run.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { createHash } from "crypto";
|
|
19
|
+
|
|
20
|
+
export interface Baseline {
|
|
21
|
+
/** Commit the baseline was recorded at. */
|
|
22
|
+
commit: string;
|
|
23
|
+
/** Runner the failures were parsed from — a baseline is not portable across runners. */
|
|
24
|
+
runner: string;
|
|
25
|
+
/**
|
|
26
|
+
* Signature of the test selection the baseline was recorded with. A baseline
|
|
27
|
+
* taken over one scoped subset says nothing about tests it never ran, so a
|
|
28
|
+
* differing scope must not be subtracted.
|
|
29
|
+
*/
|
|
30
|
+
scopeKey: string;
|
|
31
|
+
/** Failing test identifiers, or null when the output could not be parsed. */
|
|
32
|
+
failures: string[] | null;
|
|
33
|
+
/** True when the baseline run itself passed cleanly. */
|
|
34
|
+
clean: boolean;
|
|
35
|
+
recorded_at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where a baseline came from, reported back to the caller. */
|
|
39
|
+
export type BaselineSource = "cache" | "recorded" | "none";
|
|
40
|
+
|
|
41
|
+
export interface BaselineReport {
|
|
42
|
+
source: BaselineSource;
|
|
43
|
+
commit?: string;
|
|
44
|
+
/** Failures already present before the edit. */
|
|
45
|
+
preExisting?: string[];
|
|
46
|
+
/** Failures present now that were not in the baseline. */
|
|
47
|
+
newFailures?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* False when the comparison could not be trusted — no baseline, a different
|
|
50
|
+
* runner, or unparseable output on either side. The caller must then treat
|
|
51
|
+
* any failure as a failure.
|
|
52
|
+
*/
|
|
53
|
+
comparable: boolean;
|
|
54
|
+
/** Human-readable explanation, always set. */
|
|
55
|
+
reason: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function baselineDir(): string {
|
|
59
|
+
// Overridable so tests can isolate the cache. Left as the real global path by
|
|
60
|
+
// default, but the project's own suite must point it elsewhere: otherwise the
|
|
61
|
+
// suite records baselines for ITS OWN commit and a stale entry then suppresses
|
|
62
|
+
// a later real run's regression. HASHPILOT_VERIFY_BASELINE_DIR is read at
|
|
63
|
+
// call time, so a test flips it for one scenario and the next reads a fresh dir.
|
|
64
|
+
return (
|
|
65
|
+
process.env.HASHPILOT_VERIFY_BASELINE_DIR ||
|
|
66
|
+
join(homedir(), ".agentic-tools", "verify-baselines")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function baselineKey(rootDir: string, commit: string, runner: string, scopeKey: string): string {
|
|
71
|
+
const h = createHash("sha256")
|
|
72
|
+
.update(`${rootDir}\0${commit}\0${runner}\0${scopeKey}`)
|
|
73
|
+
.digest("hex")
|
|
74
|
+
.slice(0, 32);
|
|
75
|
+
return join(baselineDir(), `${h}.json`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Current commit SHA for `rootDir`, or undefined outside a git repo. A dirty
|
|
80
|
+
* tree still maps to its HEAD commit: the baseline records which tests were
|
|
81
|
+
* broken at that commit, and uncommitted edits are exactly what we are testing.
|
|
82
|
+
*/
|
|
83
|
+
export async function currentCommit(rootDir: string): Promise<string | undefined> {
|
|
84
|
+
try {
|
|
85
|
+
const proc = Bun.spawn(["git", "-C", rootDir, "rev-parse", "HEAD"], {
|
|
86
|
+
stdout: "pipe",
|
|
87
|
+
stderr: "ignore",
|
|
88
|
+
});
|
|
89
|
+
const sha = (await new Response(proc.stdout).text()).trim();
|
|
90
|
+
const code = await proc.exited;
|
|
91
|
+
return code === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Stable signature for a test selection, used to key baselines. */
|
|
98
|
+
export function scopeSignature(args: string[]): string {
|
|
99
|
+
return createHash("sha256").update([...args].sort().join("\0")).digest("hex").slice(0, 16);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readBaseline(
|
|
103
|
+
rootDir: string,
|
|
104
|
+
commit: string,
|
|
105
|
+
runner: string,
|
|
106
|
+
scopeKey: string
|
|
107
|
+
): Promise<Baseline | undefined> {
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readFile(baselineKey(rootDir, commit, runner, scopeKey), "utf8");
|
|
110
|
+
const parsed = JSON.parse(raw) as Baseline;
|
|
111
|
+
return parsed.commit === commit && parsed.runner === runner && parsed.scopeKey === scopeKey
|
|
112
|
+
? parsed
|
|
113
|
+
: undefined;
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function writeBaseline(rootDir: string, baseline: Baseline): Promise<void> {
|
|
120
|
+
await mkdir(baselineDir(), { recursive: true });
|
|
121
|
+
await writeFile(
|
|
122
|
+
baselineKey(rootDir, baseline.commit, baseline.runner, baseline.scopeKey),
|
|
123
|
+
JSON.stringify(baseline, null, 2),
|
|
124
|
+
"utf8"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compare a post-edit run against a baseline.
|
|
130
|
+
*
|
|
131
|
+
* Every uncertainty resolves to `comparable: false`. Subtracting a baseline we
|
|
132
|
+
* are not sure about would suppress a real regression, which is worse than
|
|
133
|
+
* making the caller re-run the suite themselves.
|
|
134
|
+
*/
|
|
135
|
+
export function compareToBaseline(
|
|
136
|
+
baseline: Baseline | undefined,
|
|
137
|
+
runner: string,
|
|
138
|
+
scopeKey: string,
|
|
139
|
+
currentFailures: string[] | null
|
|
140
|
+
): BaselineReport {
|
|
141
|
+
if (!baseline) {
|
|
142
|
+
return {
|
|
143
|
+
source: "none",
|
|
144
|
+
comparable: false,
|
|
145
|
+
reason: "no baseline recorded for this commit; every failure counts",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (baseline.runner !== runner) {
|
|
149
|
+
return {
|
|
150
|
+
source: "cache",
|
|
151
|
+
commit: baseline.commit,
|
|
152
|
+
comparable: false,
|
|
153
|
+
reason: `baseline was recorded with "${baseline.runner}" but this run used "${runner}"`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (baseline.scopeKey !== scopeKey) {
|
|
157
|
+
return {
|
|
158
|
+
source: "cache",
|
|
159
|
+
commit: baseline.commit,
|
|
160
|
+
comparable: false,
|
|
161
|
+
reason: "baseline was recorded over a different set of tests than this run covered",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (baseline.failures === null || currentFailures === null) {
|
|
165
|
+
return {
|
|
166
|
+
source: "cache",
|
|
167
|
+
commit: baseline.commit,
|
|
168
|
+
comparable: false,
|
|
169
|
+
reason: "test output could not be parsed into individual test names; every failure counts",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const known = new Set(baseline.failures);
|
|
174
|
+
const newFailures = currentFailures.filter((f) => !known.has(f));
|
|
175
|
+
return {
|
|
176
|
+
source: "cache",
|
|
177
|
+
commit: baseline.commit,
|
|
178
|
+
preExisting: baseline.failures,
|
|
179
|
+
newFailures,
|
|
180
|
+
comparable: true,
|
|
181
|
+
reason:
|
|
182
|
+
newFailures.length > 0
|
|
183
|
+
? `${newFailures.length} test(s) newly failing vs the baseline at ${baseline.commit.slice(0, 8)}`
|
|
184
|
+
: `all failures were already failing at ${baseline.commit.slice(0, 8)}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test scoping and failure extraction for `verify-changes` (issue #24).
|
|
3
|
+
*
|
|
4
|
+
* Two jobs, both about making verification usable instead of merely correct:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Scoping** — run the tests that relate to the changed files, not the
|
|
7
|
+
* whole suite. A caller must be able to tell which it got, so every
|
|
8
|
+
* invocation carries `scoped` plus a human-readable `reason`.
|
|
9
|
+
* 2. **Failure extraction** — pull individual failing test names out of a
|
|
10
|
+
* runner's output so a pre-edit baseline can be subtracted from a post-edit
|
|
11
|
+
* run. Parsing is best-effort per runner; when it cannot be trusted the
|
|
12
|
+
* parser returns `null` and callers must fall back to "any failure fails".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from "fs";
|
|
16
|
+
import { basename, dirname, extname, isAbsolute, join, relative } from "path";
|
|
17
|
+
|
|
18
|
+
/** How the test command was assembled, and whether it covers the whole suite. */
|
|
19
|
+
export interface TestInvocation {
|
|
20
|
+
/** Command string, still subject to the binary allowlist in verify.ts. */
|
|
21
|
+
cmd: string;
|
|
22
|
+
/** Arguments appended after the command's own built-in args. */
|
|
23
|
+
args: string[];
|
|
24
|
+
/** False means the full suite ran. */
|
|
25
|
+
scoped: boolean;
|
|
26
|
+
/** Why it is scoped (or why it could not be) — surfaced in VerifyResult. */
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Base command per runner. Kept separate from the scoped forms below so a
|
|
31
|
+
* scoped `go test` does not inherit `./...` from the unscoped default. */
|
|
32
|
+
const RUNNER_BASE: Record<string, string> = {
|
|
33
|
+
"bun test": "bun test",
|
|
34
|
+
vitest: "npx --no-install vitest run",
|
|
35
|
+
jest: "npx --no-install jest",
|
|
36
|
+
pytest: "python -m pytest",
|
|
37
|
+
"go test": "go test",
|
|
38
|
+
"cargo test": "cargo test",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Unscoped form, used when scoping is impossible. */
|
|
42
|
+
const RUNNER_FULL: Record<string, string> = {
|
|
43
|
+
...RUNNER_BASE,
|
|
44
|
+
"go test": "go test ./...",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TEST_FILE_PATTERNS = [
|
|
48
|
+
/\.(test|spec)\.[cm]?[jt]sx?$/,
|
|
49
|
+
/(^|\/)test_[^/]+\.py$/,
|
|
50
|
+
/_test\.py$/,
|
|
51
|
+
/_test\.go$/,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Classification is by a file's OWN name, never the directory it sits in. A
|
|
55
|
+
// tests-location pattern used to flag a file merely because its absolute path
|
|
56
|
+
// passed through a `tests/` directory: that misclassified a source file under a
|
|
57
|
+
// `tests/` dir as a test, fed the source into the run, and starved the
|
|
58
|
+
// sibling-search that would find the real `foo.test.ts`. The `tests/` location is
|
|
59
|
+
// already covered on the derivation side in `relatedTestFiles` (which probes
|
|
60
|
+
// `../tests/<base>.test.<ext>`), so the input side stays name-based.
|
|
61
|
+
function looksLikeTestFile(file: string): boolean {
|
|
62
|
+
return TEST_FILE_PATTERNS.some((re) => re.test(file));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Candidate test files for a source file, by the conventions each ecosystem
|
|
67
|
+
* actually uses. Only paths that exist are returned — a guess that does not
|
|
68
|
+
* resolve is worse than falling back to the full suite, because it makes the
|
|
69
|
+
* runner exit non-zero on a missing path and reads as a broken change.
|
|
70
|
+
*/
|
|
71
|
+
function relatedTestFiles(file: string): string[] {
|
|
72
|
+
const dir = dirname(file);
|
|
73
|
+
const ext = extname(file);
|
|
74
|
+
const base = basename(file, ext);
|
|
75
|
+
const candidates: string[] = [];
|
|
76
|
+
|
|
77
|
+
if (/^\.[cm]?[jt]sx?$/.test(ext)) {
|
|
78
|
+
for (const suffix of [".test", ".spec"]) {
|
|
79
|
+
for (const e of [ext, ".ts", ".js"]) {
|
|
80
|
+
candidates.push(join(dir, `${base}${suffix}${e}`));
|
|
81
|
+
candidates.push(join(dir, "__tests__", `${base}${suffix}${e}`));
|
|
82
|
+
candidates.push(join(dir, "..", "tests", `${base}${suffix}${e}`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} else if (ext === ".py") {
|
|
86
|
+
candidates.push(join(dir, `test_${base}.py`));
|
|
87
|
+
candidates.push(join(dir, `${base}_test.py`));
|
|
88
|
+
candidates.push(join(dir, "..", "tests", `test_${base}.py`));
|
|
89
|
+
candidates.push(join(dir, "tests", `test_${base}.py`));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return candidates.filter((c) => existsSync(c));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the test command for a run over `files`.
|
|
97
|
+
*
|
|
98
|
+
* `--test-filter` and scoping are independent: a name filter narrows *within*
|
|
99
|
+
* whatever set of files is selected, so both can apply at once.
|
|
100
|
+
*/
|
|
101
|
+
export function buildTestInvocation(
|
|
102
|
+
runner: string,
|
|
103
|
+
files: string[],
|
|
104
|
+
rootDir: string,
|
|
105
|
+
opts: { scope?: boolean } = {}
|
|
106
|
+
): TestInvocation {
|
|
107
|
+
const full = RUNNER_FULL[runner] || runner;
|
|
108
|
+
if (opts.scope === false) {
|
|
109
|
+
return { cmd: full, args: [], scoped: false, reason: "scoping disabled by caller" };
|
|
110
|
+
}
|
|
111
|
+
if (files.length === 0) {
|
|
112
|
+
return { cmd: full, args: [], scoped: false, reason: "no files given to scope to" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const base = RUNNER_BASE[runner] || runner;
|
|
116
|
+
|
|
117
|
+
switch (runner) {
|
|
118
|
+
case "jest":
|
|
119
|
+
// Jest's own related-test discovery: walks the module graph, so it finds
|
|
120
|
+
// tests that import the changed file indirectly. Strictly better than any
|
|
121
|
+
// path convention we could guess.
|
|
122
|
+
return {
|
|
123
|
+
cmd: base,
|
|
124
|
+
args: ["--findRelatedTests", ...files],
|
|
125
|
+
scoped: true,
|
|
126
|
+
reason: "jest --findRelatedTests over changed files",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
case "vitest":
|
|
130
|
+
// Single `--related=a,b` rather than a repeated flag: a bare positional
|
|
131
|
+
// list after `--related` is parsed as test-name filters by some versions.
|
|
132
|
+
return {
|
|
133
|
+
cmd: base,
|
|
134
|
+
args: [`--related=${files.join(",")}`],
|
|
135
|
+
scoped: true,
|
|
136
|
+
reason: "vitest --related over changed files",
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
case "bun test":
|
|
140
|
+
case "pytest": {
|
|
141
|
+
// Neither runner has related-test discovery, so use the changed test
|
|
142
|
+
// files directly and fall back to convention-derived ones.
|
|
143
|
+
|
|
144
|
+
// changed file is a test, a direct test is treated as the author's
|
|
145
|
+
// signal of what to run, so a changed source file's sibling test is only
|
|
146
|
+
// derived when no direct test is co-listed; list that source's test
|
|
147
|
+
// explicitly instead.
|
|
148
|
+
const direct = files.filter(looksLikeTestFile);
|
|
149
|
+
const derived = direct.length > 0 ? [] : files.flatMap(relatedTestFiles);
|
|
150
|
+
const targets = [...new Set([...direct, ...derived])];
|
|
151
|
+
if (targets.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
cmd: full,
|
|
154
|
+
args: [],
|
|
155
|
+
scoped: false,
|
|
156
|
+
reason: `no test files found for the changed files; ran the full ${runner} suite`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
cmd: base,
|
|
161
|
+
args: targets,
|
|
162
|
+
scoped: true,
|
|
163
|
+
reason: `${runner} restricted to ${targets.length} related test file(s)`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case "go test": {
|
|
168
|
+
// Go scopes by package, which is the directory.
|
|
169
|
+
const pkgs = [...new Set(
|
|
170
|
+
files.filter((f) => f.endsWith(".go")).map((f) => {
|
|
171
|
+
const dir = dirname(isAbsolute(f) ? relative(rootDir, f) : f);
|
|
172
|
+
return dir === "." || dir === "" ? "." : `./${dir}`;
|
|
173
|
+
})
|
|
174
|
+
)];
|
|
175
|
+
if (pkgs.length === 0) {
|
|
176
|
+
return { cmd: full, args: [], scoped: false, reason: "no .go files to scope to" };
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
cmd: base,
|
|
180
|
+
args: pkgs,
|
|
181
|
+
scoped: true,
|
|
182
|
+
reason: `go test restricted to ${pkgs.length} package(s)`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "cargo test": {
|
|
187
|
+
// Only integration tests (tests/*.rs) are individually selectable;
|
|
188
|
+
// a change under src/ can affect any unit test in the crate.
|
|
189
|
+
const integration = files
|
|
190
|
+
.filter((f) => /(^|\/)tests\/[^/]+\.rs$/.test(f))
|
|
191
|
+
.map((f) => basename(f, ".rs"));
|
|
192
|
+
if (integration.length === 0 || integration.length !== files.length) {
|
|
193
|
+
return {
|
|
194
|
+
cmd: full,
|
|
195
|
+
args: [],
|
|
196
|
+
scoped: false,
|
|
197
|
+
reason: "cargo cannot scope unit tests to files; ran the full crate",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
cmd: base,
|
|
202
|
+
args: [...new Set(integration)].flatMap((t) => ["--test", t]),
|
|
203
|
+
scoped: true,
|
|
204
|
+
reason: `cargo test restricted to ${integration.length} integration target(s)`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
default:
|
|
209
|
+
return {
|
|
210
|
+
cmd: full,
|
|
211
|
+
args: [],
|
|
212
|
+
scoped: false,
|
|
213
|
+
reason: `no scoping rule for runner "${runner}"; ran it unscoped`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Extract failing test identifiers from a runner's output.
|
|
220
|
+
*
|
|
221
|
+
* Returns `null` when the output shape is not recognised — the caller must then
|
|
222
|
+
* treat any failure as a real failure rather than guess. A wrong "these are the
|
|
223
|
+
* same failures as before" is the one answer that destroys work.
|
|
224
|
+
*/
|
|
225
|
+
export function parseFailures(runner: string, output: string): string[] | null {
|
|
226
|
+
const lines = output.split("\n");
|
|
227
|
+
const out: string[] = [];
|
|
228
|
+
|
|
229
|
+
switch (runner) {
|
|
230
|
+
case "bun test": {
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
const m = line.match(/^\s*\(fail\)\s+(.+?)(?:\s+\[[\d.]+\s*m?s\])?\s*$/);
|
|
233
|
+
if (m) out.push(m[1].trim());
|
|
234
|
+
}
|
|
235
|
+
return /\d+\s+fail|\(fail\)|\d+\s+pass/.test(output) ? out : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "vitest":
|
|
239
|
+
case "jest": {
|
|
240
|
+
let file = "";
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
// Only FAIL marks a file. `✗` is left out deliberately: it also marks a
|
|
243
|
+
// failing test name below, and this branch runs first — matching it here
|
|
244
|
+
// would swallow every `✗ test name` line as a filename.
|
|
245
|
+
const f = line.match(/^\s*FAIL\s+(\S+)/);
|
|
246
|
+
if (f) { file = f[1]; continue; }
|
|
247
|
+
const t = line.match(/^\s*(?:✕|×|✗)\s+(.+?)(?:\s+\(\d+\s*m?s\))?\s*$/);
|
|
248
|
+
if (t) out.push(`${file}::${t[1].trim()}`);
|
|
249
|
+
}
|
|
250
|
+
return /Tests?\s+\d+|FAIL|PASS|Test Files/.test(output) ? out : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case "pytest": {
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const m = line.match(/^FAILED\s+(\S+)/) || line.match(/^ERROR\s+(\S+)/);
|
|
256
|
+
if (m) out.push(m[1]);
|
|
257
|
+
}
|
|
258
|
+
// Without the short summary there is nothing to parse reliably; verify.ts
|
|
259
|
+
// adds `-rf` so this branch normally has data.
|
|
260
|
+
return /=+\s*(short test summary|\d+ (passed|failed))/.test(output) ? out : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case "go test": {
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
const m = line.match(/^\s*---\s+FAIL:\s+(\S+)/);
|
|
266
|
+
if (m) out.push(m[1]);
|
|
267
|
+
}
|
|
268
|
+
return /^(ok|FAIL|PASS|---)/m.test(output) ? out : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case "cargo test": {
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
const m = line.match(/^test\s+(\S+)\s+\.\.\.\s+FAILED/);
|
|
274
|
+
if (m) out.push(m[1]);
|
|
275
|
+
}
|
|
276
|
+
return /test result:/.test(output) ? out : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
default:
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|