builder-assistant-engineer 0.2.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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/analyst/prompt.js +19 -0
- package/dist/analyst/retry.js +38 -0
- package/dist/artifacts/diff.js +47 -0
- package/dist/artifacts/lessons.js +32 -0
- package/dist/artifacts/merge.js +70 -0
- package/dist/artifacts/write.js +44 -0
- package/dist/backends/agent-cli.js +174 -0
- package/dist/backends/api.js +171 -0
- package/dist/backends/index.js +10 -0
- package/dist/backends/manual.js +52 -0
- package/dist/backends/sse.js +24 -0
- package/dist/backends/types.js +1 -0
- package/dist/bin.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/commands/context.js +12 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/next.js +103 -0
- package/dist/commands/plan.js +71 -0
- package/dist/commands/replan.js +37 -0
- package/dist/commands/review.js +37 -0
- package/dist/commands/shared.js +40 -0
- package/dist/commands/status.js +95 -0
- package/dist/config/commands.js +85 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/settings.js +8 -0
- package/dist/config/store.js +38 -0
- package/dist/core/bash.js +105 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/fs.js +35 -0
- package/dist/core/git.js +104 -0
- package/dist/core/gitignore.js +14 -0
- package/dist/core/json.js +13 -0
- package/dist/core/paths.js +14 -0
- package/dist/core/process.js +56 -0
- package/dist/core/prompt-loader.js +27 -0
- package/dist/core/state.js +27 -0
- package/dist/core/which.js +24 -0
- package/dist/detect/agents.js +17 -0
- package/dist/detect/mode.js +11 -0
- package/dist/digest/baseline.js +192 -0
- package/dist/digest/budget.js +31 -0
- package/dist/digest/files.js +167 -0
- package/dist/digest/format.js +18 -0
- package/dist/digest/git.js +19 -0
- package/dist/digest/index.js +100 -0
- package/dist/digest/manifests.js +133 -0
- package/dist/digest/read.js +28 -0
- package/dist/digest/redact.js +37 -0
- package/dist/digest/render.js +138 -0
- package/dist/digest/select.js +116 -0
- package/dist/digest/todos.js +16 -0
- package/dist/digest/tree.js +55 -0
- package/dist/digest/walk.js +72 -0
- package/dist/gates/capture.js +145 -0
- package/dist/gates/contract.js +339 -0
- package/dist/gates/enforce.js +143 -0
- package/dist/gates/findings.js +17 -0
- package/dist/gates/gate.js +315 -0
- package/dist/gates/ignore-rules.js +104 -0
- package/dist/gates/protected-files.js +311 -0
- package/dist/gates/regression.js +219 -0
- package/dist/gates/reports.js +349 -0
- package/dist/gates/results.js +295 -0
- package/dist/gates/runners.js +189 -0
- package/dist/gates/state-guard.js +56 -0
- package/dist/i18n/en.js +288 -0
- package/dist/i18n/es.js +288 -0
- package/dist/i18n/index.js +18 -0
- package/dist/interview/adaptive.js +69 -0
- package/dist/interview/brief.js +27 -0
- package/dist/interview/questions.js +18 -0
- package/dist/interview/render.js +19 -0
- package/dist/interview/reply.js +34 -0
- package/dist/next/attempts.js +151 -0
- package/dist/next/start.js +179 -0
- package/dist/plan/continuation.js +40 -0
- package/dist/plan/evidence.js +239 -0
- package/dist/plan/filter.js +20 -0
- package/dist/plan/parser.js +283 -0
- package/dist/plan/prior.js +32 -0
- package/dist/plan/questions.js +48 -0
- package/dist/plan/repair.js +33 -0
- package/dist/plan/report.js +37 -0
- package/dist/plan/run.js +191 -0
- package/dist/prompts/analyst.md +101 -0
- package/dist/prompts/continue.md +11 -0
- package/dist/prompts/fix-format.md +13 -0
- package/dist/prompts/fix-paths.md +23 -0
- package/dist/prompts/lesson.md +18 -0
- package/dist/prompts/retry.md +9 -0
- package/dist/prompts/review.md +36 -0
- package/dist/prompts/task.md +9 -0
- package/dist/review/changes.js +177 -0
- package/dist/review/diff.js +115 -0
- package/dist/review/integrity.js +150 -0
- package/dist/review/mechanical.js +62 -0
- package/dist/review/parse.js +40 -0
- package/dist/review/reviewer.js +23 -0
- package/dist/review/run.js +134 -0
- package/dist/review/scope.js +55 -0
- package/dist/review/secrets.js +163 -0
- package/dist/review/snapshot.js +38 -0
- package/dist/review/tests.js +140 -0
- package/dist/tasks/attempts.js +71 -0
- package/dist/tasks/checks.js +181 -0
- package/dist/tasks/frontmatter.js +58 -0
- package/dist/tasks/graph.js +27 -0
- package/dist/tasks/handoff.js +62 -0
- package/dist/tasks/learn.js +128 -0
- package/dist/tasks/load.js +37 -0
- package/dist/tasks/metrics.js +40 -0
- package/dist/tasks/runs.js +12 -0
- package/dist/tasks/schema.js +96 -0
- package/dist/tasks/select.js +15 -0
- package/dist/tasks/shell-words.js +176 -0
- package/dist/tasks/status.js +13 -0
- package/dist/tasks/verify.js +181 -0
- package/dist/ui/clack.js +46 -0
- package/dist/ui/prompter.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { parseLine, program } from "../tasks/shell-words.js";
|
|
2
|
+
export const TASKS_DIR = "docs/plan/tasks";
|
|
3
|
+
export const WATCHED_DIRS = [
|
|
4
|
+
".bae",
|
|
5
|
+
".claude/agents",
|
|
6
|
+
".claude/commands",
|
|
7
|
+
".claude/skills",
|
|
8
|
+
".claude/hooks",
|
|
9
|
+
".opencode",
|
|
10
|
+
".gemini",
|
|
11
|
+
".codex",
|
|
12
|
+
TASKS_DIR,
|
|
13
|
+
];
|
|
14
|
+
export const AGENT_FILES = [".claude/settings.json", "opencode.json", "opencode.jsonc"];
|
|
15
|
+
export const BAE_SCRATCH = [".bae/tmp/"];
|
|
16
|
+
export const GIT_FILES = ["config", "info/attributes"];
|
|
17
|
+
const MEMORY_FILES = new Set([
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"AGENTS.override.md",
|
|
20
|
+
"CLAUDE.md",
|
|
21
|
+
"CLAUDE.local.md",
|
|
22
|
+
"GEMINI.md",
|
|
23
|
+
]);
|
|
24
|
+
const TOOLCHAIN_NAMES = new Set([
|
|
25
|
+
".npmrc",
|
|
26
|
+
".yarnrc",
|
|
27
|
+
".yarnrc.yml",
|
|
28
|
+
".pnpmfile.cjs",
|
|
29
|
+
"bunfig.toml",
|
|
30
|
+
".envrc",
|
|
31
|
+
]);
|
|
32
|
+
const TOOLCHAIN_DIRS = [".yarn/releases/", ".yarn/plugins/"];
|
|
33
|
+
const RUNNER_FILES = [
|
|
34
|
+
/^vitest\.(config|workspace)\.[cm]?[jt]s$/,
|
|
35
|
+
/^vite\.config\.[cm]?[jt]s$/,
|
|
36
|
+
/^jest\.config\.([cm]?[jt]s|json)$/,
|
|
37
|
+
/^playwright\.config\.[cm]?[jt]s$/,
|
|
38
|
+
/^cypress\.config\.[cm]?[jt]s$/,
|
|
39
|
+
/^karma\.conf\.[cm]?[jt]s$/,
|
|
40
|
+
/^\.mocharc(\.(c?js|jsonc?|ya?ml))?$/,
|
|
41
|
+
/^pytest\.ini$/,
|
|
42
|
+
/^conftest\.py$/,
|
|
43
|
+
/^phpunit\.xml(\.dist)?$/,
|
|
44
|
+
/^\.rspec$/,
|
|
45
|
+
/^tsconfig(\.[\w-]+)?\.json$/,
|
|
46
|
+
/^jsconfig\.json$/,
|
|
47
|
+
/^eslint\.config\.[cm]?[jt]s$/,
|
|
48
|
+
/^\.eslintrc(\.(c?js|json|ya?ml))?$/,
|
|
49
|
+
/^\.eslintignore$/,
|
|
50
|
+
/^biome\.jsonc?$/,
|
|
51
|
+
/^\.?ruff\.toml$/,
|
|
52
|
+
/^\.flake8$/,
|
|
53
|
+
/^\.?mypy\.ini$/,
|
|
54
|
+
/^\.?pylintrc$/,
|
|
55
|
+
/^\.golangci\.(ya?ml|toml|json)$/,
|
|
56
|
+
/^\.?clippy\.toml$/,
|
|
57
|
+
/^\.stylelintrc(\.\w+)?$/,
|
|
58
|
+
/^phpstan\.neon(\.dist)?$/,
|
|
59
|
+
/^psalm\.xml$/,
|
|
60
|
+
/^\.rubocop\.yml$/,
|
|
61
|
+
/^turbo\.json$/,
|
|
62
|
+
/^nx\.json$/,
|
|
63
|
+
/^project\.json$/,
|
|
64
|
+
/^angular\.json$/,
|
|
65
|
+
/^deno\.jsonc?$/,
|
|
66
|
+
/^\.babelrc(\.\w+)?$/,
|
|
67
|
+
/^babel\.config\.\w+$/,
|
|
68
|
+
/^\.prettierrc(\.\w+)?$/,
|
|
69
|
+
/^prettier\.config\.\w+$/,
|
|
70
|
+
/^\.coveragerc$/,
|
|
71
|
+
/^\.nycrc(\.\w+)?$/,
|
|
72
|
+
/^\.c8rc(\.\w+)?$/,
|
|
73
|
+
/^jest\.setup\.[cm]?[jt]s$/,
|
|
74
|
+
];
|
|
75
|
+
const SECTIONS = {
|
|
76
|
+
"pyproject.toml": [
|
|
77
|
+
"tool.pytest",
|
|
78
|
+
"tool.ruff",
|
|
79
|
+
"tool.mypy",
|
|
80
|
+
"tool.coverage",
|
|
81
|
+
"tool.poe",
|
|
82
|
+
"tool.hatch.envs",
|
|
83
|
+
"tool.pdm.scripts",
|
|
84
|
+
],
|
|
85
|
+
"setup.cfg": ["tool:pytest", "flake8", "mypy", "coverage"],
|
|
86
|
+
"tox.ini": ["pytest", "flake8", "testenv", "tox"],
|
|
87
|
+
"Cargo.toml": ["lints", "workspace.lints"],
|
|
88
|
+
};
|
|
89
|
+
const RUNNER_CONFIGS = /^(vitest|vite|jest|playwright|cypress)\.config\.([cm]?[jt]s|json)$|^\.mocharc|^package\.json$|^\.rspec$/;
|
|
90
|
+
const SETUP_KEYS = /["']?\b(setupFiles|setupFilesAfterEach|setupFilesAfterEnv|globalSetup|globalTeardown|require|setupFilesAfterFramework)\b["']?\s*[:=]\s*(\[[^\]]*\]|["'][^"']+["'])|(--require)\s+()(\S+)/g;
|
|
91
|
+
const INTERPRETERS = new Set([
|
|
92
|
+
"node",
|
|
93
|
+
"tsx",
|
|
94
|
+
"ts-node",
|
|
95
|
+
"python",
|
|
96
|
+
"python3",
|
|
97
|
+
"py",
|
|
98
|
+
"bash",
|
|
99
|
+
"sh",
|
|
100
|
+
"ruby",
|
|
101
|
+
"perl",
|
|
102
|
+
"php",
|
|
103
|
+
"deno",
|
|
104
|
+
"bun",
|
|
105
|
+
]);
|
|
106
|
+
const PYTHONS = new Set(["python", "python3", "py"]);
|
|
107
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
108
|
+
const EXEC_WRAPPERS = new Set(["npx", "pnpx", "bunx"]);
|
|
109
|
+
const VALUE_FLAGS = new Set([
|
|
110
|
+
"--prefix",
|
|
111
|
+
"-C",
|
|
112
|
+
"--dir",
|
|
113
|
+
"-w",
|
|
114
|
+
"--workspace",
|
|
115
|
+
"--filter",
|
|
116
|
+
"-F",
|
|
117
|
+
"--cwd",
|
|
118
|
+
]);
|
|
119
|
+
const MAKEFILES = ["GNUmakefile", "makefile", "Makefile"];
|
|
120
|
+
const ACCEPTABLE = new Set([
|
|
121
|
+
"memory",
|
|
122
|
+
"gitignore",
|
|
123
|
+
"git",
|
|
124
|
+
"scripts",
|
|
125
|
+
"runner",
|
|
126
|
+
"toolchain",
|
|
127
|
+
]);
|
|
128
|
+
export function isAcceptableKind(kind) {
|
|
129
|
+
return ACCEPTABLE.has(kind);
|
|
130
|
+
}
|
|
131
|
+
export function kindOf(path, taskPath) {
|
|
132
|
+
if (path === taskPath)
|
|
133
|
+
return "task";
|
|
134
|
+
if (path.startsWith(`${TASKS_DIR}/`))
|
|
135
|
+
return "tasks";
|
|
136
|
+
if (path.startsWith(".bae/"))
|
|
137
|
+
return "bae";
|
|
138
|
+
if (path.startsWith(".git/"))
|
|
139
|
+
return "gitdir";
|
|
140
|
+
if ([".claude/", ".opencode/", ".gemini/", ".codex/"].some((dir) => path.startsWith(dir)) ||
|
|
141
|
+
AGENT_FILES.includes(path)) {
|
|
142
|
+
return "agents";
|
|
143
|
+
}
|
|
144
|
+
if (MEMORY_FILES.has(baseName(path)))
|
|
145
|
+
return "memory";
|
|
146
|
+
const name = baseName(path);
|
|
147
|
+
if (name === ".gitignore")
|
|
148
|
+
return "gitignore";
|
|
149
|
+
if (["turbo.json", "nx.json", "project.json", "angular.json", "deno.json", "deno.jsonc"].includes(name)) {
|
|
150
|
+
return "runner";
|
|
151
|
+
}
|
|
152
|
+
if (name === ".gitattributes")
|
|
153
|
+
return "git";
|
|
154
|
+
if (name === "package.json")
|
|
155
|
+
return "scripts";
|
|
156
|
+
if (isToolchain(path))
|
|
157
|
+
return "toolchain";
|
|
158
|
+
return "runner";
|
|
159
|
+
}
|
|
160
|
+
export function repoProtected(path) {
|
|
161
|
+
if (path.split("/").includes("node_modules"))
|
|
162
|
+
return false;
|
|
163
|
+
const name = baseName(path);
|
|
164
|
+
return (MEMORY_FILES.has(baseName(path)) ||
|
|
165
|
+
name === ".gitignore" ||
|
|
166
|
+
name === ".gitattributes" ||
|
|
167
|
+
name === "package.json" ||
|
|
168
|
+
isToolchain(path) ||
|
|
169
|
+
Object.hasOwn(SECTIONS, name) ||
|
|
170
|
+
RUNNER_FILES.some((pattern) => pattern.test(name)));
|
|
171
|
+
}
|
|
172
|
+
export function createdIsViolation(path) {
|
|
173
|
+
if (path.split("/").includes("node_modules"))
|
|
174
|
+
return false;
|
|
175
|
+
return (MEMORY_FILES.has(baseName(path)) || baseName(path) === ".gitattributes" || isToolchain(path));
|
|
176
|
+
}
|
|
177
|
+
export function sectionsOf(path) {
|
|
178
|
+
return SECTIONS[baseName(path)];
|
|
179
|
+
}
|
|
180
|
+
export function isRunnerConfig(path) {
|
|
181
|
+
return RUNNER_CONFIGS.test(baseName(path));
|
|
182
|
+
}
|
|
183
|
+
export function setupReferences(configPath, text) {
|
|
184
|
+
const dir = configPath.split("/").slice(0, -1).join("/");
|
|
185
|
+
const found = [];
|
|
186
|
+
for (const match of text.matchAll(SETUP_KEYS)) {
|
|
187
|
+
const values = match[2] ?? (match[5] ? `"${match[5]}"` : "");
|
|
188
|
+
for (const quoted of values.matchAll(/["']([^"']+)["']/g)) {
|
|
189
|
+
let raw = (quoted[1] ?? "").replace(/^<rootDir>\//, "").replace(/^\.\//, "");
|
|
190
|
+
if (configPath.endsWith(".rspec") && !raw.includes("."))
|
|
191
|
+
raw = `spec/${raw}.rb`;
|
|
192
|
+
if (!looksLikePath(raw))
|
|
193
|
+
continue;
|
|
194
|
+
found.push(dir ? `${dir}/${raw}` : raw);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
export function executedBy(commands, scripts) {
|
|
200
|
+
const files = new Set();
|
|
201
|
+
const modules = new Set();
|
|
202
|
+
let make = false;
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
const pending = [...commands];
|
|
205
|
+
const visit = (name, args) => {
|
|
206
|
+
const wrapped = execTarget(name, args);
|
|
207
|
+
if (wrapped) {
|
|
208
|
+
visit(wrapped[0] ?? "", wrapped.slice(1));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const script of scriptsInvoked(name, args, scripts)) {
|
|
212
|
+
if (typeof scripts[script] === "string")
|
|
213
|
+
pending.push(scripts[script]);
|
|
214
|
+
}
|
|
215
|
+
if (name === "make") {
|
|
216
|
+
make = true;
|
|
217
|
+
for (const file of MAKEFILES)
|
|
218
|
+
files.add(file);
|
|
219
|
+
const flag = args.indexOf("-f");
|
|
220
|
+
if (flag !== -1 && args[flag + 1])
|
|
221
|
+
files.add(clean(args[flag + 1] ?? ""));
|
|
222
|
+
}
|
|
223
|
+
if (looksLikePath(name) || /^\.\/(gradlew|mvnw)$/.test(name))
|
|
224
|
+
files.add(clean(name));
|
|
225
|
+
if (!INTERPRETERS.has(baseName(name)))
|
|
226
|
+
return;
|
|
227
|
+
const moduleIndex = args.indexOf("-m");
|
|
228
|
+
if (PYTHONS.has(baseName(name)) && moduleIndex !== -1) {
|
|
229
|
+
const module = (args[moduleIndex + 1] ?? "").split(".")[0];
|
|
230
|
+
if (module)
|
|
231
|
+
modules.add(module);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const target = args.filter((arg) => !arg.startsWith("-") && arg !== "run")[0];
|
|
235
|
+
if (target && looksLikePath(target))
|
|
236
|
+
files.add(clean(target));
|
|
237
|
+
};
|
|
238
|
+
while (pending.length > 0) {
|
|
239
|
+
const command = pending.shift() ?? "";
|
|
240
|
+
if (seen.has(command))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(command);
|
|
243
|
+
for (const simple of parseLine(command).commands) {
|
|
244
|
+
const { name, args } = program(simple);
|
|
245
|
+
visit(name, args);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { files: [...files], modules: [...modules], make };
|
|
249
|
+
}
|
|
250
|
+
export function shadowCandidates(executed) {
|
|
251
|
+
return [
|
|
252
|
+
...(executed.make ? MAKEFILES : []),
|
|
253
|
+
...executed.modules.flatMap((module) => [`${module}.py`, `${module}/`]),
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
export function looksLikePath(word) {
|
|
257
|
+
if (word.includes("..") || word.startsWith("/") || /^[a-z]+:/i.test(word))
|
|
258
|
+
return false;
|
|
259
|
+
if (/^(\.\/)?[\w@.-]+(\/[\w@.-]+)*\.(c?js|mjs|ts|mts|cts|tsx|jsx|py|sh|bash|rb|php|pl|ps1|cmd|bat)$/.test(word)) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return /^(\.\/[\w@.-]+|[\w@.-]+(\/[\w@.-]+)+)$/.test(word);
|
|
263
|
+
}
|
|
264
|
+
export function baseName(path) {
|
|
265
|
+
return path.split("/").at(-1) ?? path;
|
|
266
|
+
}
|
|
267
|
+
function isToolchain(path) {
|
|
268
|
+
return TOOLCHAIN_NAMES.has(baseName(path)) || TOOLCHAIN_DIRS.some((dir) => path.startsWith(dir));
|
|
269
|
+
}
|
|
270
|
+
export function mainScript(name, args, scripts) {
|
|
271
|
+
return scriptsInvoked(name, args, scripts).find((script) => !/^(pre|post)/.test(script));
|
|
272
|
+
}
|
|
273
|
+
function scriptsInvoked(name, args, scripts) {
|
|
274
|
+
if (!PACKAGE_MANAGERS.has(name))
|
|
275
|
+
return [];
|
|
276
|
+
const rest = positional(args);
|
|
277
|
+
let script;
|
|
278
|
+
if (rest[0] === "run" || rest[0] === "run-script" || rest[0] === "run-s")
|
|
279
|
+
script = rest[1];
|
|
280
|
+
else if (rest[0] === "test" || rest[0] === "t" || rest[0] === "tst")
|
|
281
|
+
script = "test";
|
|
282
|
+
else if (name !== "npm" && rest[0] && typeof scripts[rest[0]] === "string")
|
|
283
|
+
script = rest[0];
|
|
284
|
+
return script ? [`pre${script}`, script, `post${script}`] : [];
|
|
285
|
+
}
|
|
286
|
+
export function execTarget(name, args) {
|
|
287
|
+
if (EXEC_WRAPPERS.has(name))
|
|
288
|
+
return positional(args, false);
|
|
289
|
+
if (!PACKAGE_MANAGERS.has(name))
|
|
290
|
+
return undefined;
|
|
291
|
+
const rest = positional(args, false);
|
|
292
|
+
return rest[0] === "exec" || rest[0] === "dlx" ? rest.slice(1) : undefined;
|
|
293
|
+
}
|
|
294
|
+
export function positional(args, dropValues = true) {
|
|
295
|
+
const rest = [];
|
|
296
|
+
for (let index = 0; index < args.length; index++) {
|
|
297
|
+
const arg = args[index] ?? "";
|
|
298
|
+
if (!arg.startsWith("-")) {
|
|
299
|
+
rest.push(...(dropValues ? [arg] : args.slice(index)));
|
|
300
|
+
if (!dropValues)
|
|
301
|
+
break;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (VALUE_FLAGS.has(arg))
|
|
305
|
+
index++;
|
|
306
|
+
}
|
|
307
|
+
return rest;
|
|
308
|
+
}
|
|
309
|
+
function clean(path) {
|
|
310
|
+
return path.replace(/^\.\//, "");
|
|
311
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { bashPath, runScript } from "../core/bash.js";
|
|
3
|
+
import { goText, probeFor, REPORT_SOURCES } from "./reports.js";
|
|
4
|
+
import { cargoUnits, countsSchema, errorLines, parseFailing, parseSummary, runnerHint, } from "./results.js";
|
|
5
|
+
import { expandCommand, readSources, testRunners } from "./runners.js";
|
|
6
|
+
export const SUITE_KEYS = ["lint", "typecheck", "build", "test"];
|
|
7
|
+
export const commandBaselineSchema = z.object({
|
|
8
|
+
exitCode: z.number(),
|
|
9
|
+
counts: countsSchema.optional(),
|
|
10
|
+
failing: z.array(z.string()).optional(),
|
|
11
|
+
source: z.string().optional(),
|
|
12
|
+
units: z.array(z.string()).optional(),
|
|
13
|
+
errors: z.array(z.string()).optional(),
|
|
14
|
+
absent: z.boolean().optional(),
|
|
15
|
+
});
|
|
16
|
+
export const suiteBaselineSchema = z.object({
|
|
17
|
+
skipped: z.boolean().default(false),
|
|
18
|
+
commands: z.record(z.string(), commandBaselineSchema).default({}),
|
|
19
|
+
excluded: z.array(z.string()).default([]),
|
|
20
|
+
});
|
|
21
|
+
const OUTPUT_TAIL = 3_000;
|
|
22
|
+
const BLOCKING = new Set([
|
|
23
|
+
"regression",
|
|
24
|
+
"unfinished",
|
|
25
|
+
"noBaseline",
|
|
26
|
+
"uncomparable",
|
|
27
|
+
"mustPass",
|
|
28
|
+
"unknown",
|
|
29
|
+
]);
|
|
30
|
+
const VERDICTS = {
|
|
31
|
+
passed: "passed",
|
|
32
|
+
preexisting: "preexisting: it already failed before the task and the task did not make it worse",
|
|
33
|
+
regression: "regression: it passed before the task, or more checks fail than before",
|
|
34
|
+
unfinished: "regression: it did not finish (timeout or could not start)",
|
|
35
|
+
noBaseline: "regression: there is no passing baseline for it",
|
|
36
|
+
uncomparable: "blocks: it already failed before the task and its output has no counts to compare, so only green passes",
|
|
37
|
+
mustPass: "blocks: the task is tests: fix, so the test suite must end green",
|
|
38
|
+
unknown: "blocks: bae could not read how many tests ran, so it cannot tell whether they passed",
|
|
39
|
+
};
|
|
40
|
+
export function suiteCommands(config) {
|
|
41
|
+
if (config.gates.regression === "off")
|
|
42
|
+
return [];
|
|
43
|
+
return SUITE_KEYS.flatMap((key) => {
|
|
44
|
+
const command = config.commands[key];
|
|
45
|
+
return command ? [{ key, command }] : [];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export async function runSuite(cwd, suite, onOutput, timeoutMs) {
|
|
49
|
+
const results = [];
|
|
50
|
+
const bash = await bashPath();
|
|
51
|
+
const sources = await readSources(cwd);
|
|
52
|
+
for (const item of suite) {
|
|
53
|
+
onOutput?.(`$ ${item.command}\n`);
|
|
54
|
+
results.push(bash
|
|
55
|
+
? await runOne(bash, { cwd, item, sources, timeoutMs }, onOutput)
|
|
56
|
+
: { ...item, exitCode: -1, output: "bash was not found" });
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
async function runOne(bash, run, onOutput) {
|
|
61
|
+
const { cwd, item, sources } = run;
|
|
62
|
+
const test = item.key === "test";
|
|
63
|
+
const hint = test
|
|
64
|
+
? testRunners(item.command, sources)
|
|
65
|
+
: runnerHint(expandCommand(item.command, sources));
|
|
66
|
+
const probe = test && hint.length > 0 ? await probeFor(cwd, item.command, sources) : undefined;
|
|
67
|
+
try {
|
|
68
|
+
const view = probe?.view?.();
|
|
69
|
+
const shown = onOutput && view
|
|
70
|
+
? (chunk) => {
|
|
71
|
+
const text = view.push(chunk);
|
|
72
|
+
if (text)
|
|
73
|
+
onOutput(text);
|
|
74
|
+
}
|
|
75
|
+
: onOutput;
|
|
76
|
+
const result = await runScript(bash, `set -o pipefail\nexport CI=true\n${probe?.command ?? item.command}\n`, {
|
|
77
|
+
cwd,
|
|
78
|
+
...(shown ? { onOutput: shown } : {}),
|
|
79
|
+
...(probe ? { env: probe.env } : {}),
|
|
80
|
+
...(run.timeoutMs ? { timeoutMs: run.timeoutMs } : {}),
|
|
81
|
+
});
|
|
82
|
+
const rest = view?.end();
|
|
83
|
+
if (rest)
|
|
84
|
+
onOutput?.(rest);
|
|
85
|
+
const output = view ? goText(result.output) : result.output;
|
|
86
|
+
const read = (await probe?.collect(result.stdout)) ?? textRead(output, hint, test);
|
|
87
|
+
const errors = !test && result.exitCode !== 0 ? errorLines(output) : [];
|
|
88
|
+
return {
|
|
89
|
+
...item,
|
|
90
|
+
exitCode: result.exitCode,
|
|
91
|
+
output,
|
|
92
|
+
...read,
|
|
93
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
94
|
+
...(test && hint.length === 0 ? { unrecognized: true } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await probe?.dispose();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function textRead(output, hint, test) {
|
|
102
|
+
if (test && hint.length === 0)
|
|
103
|
+
return {};
|
|
104
|
+
const summary = parseSummary(output, hint);
|
|
105
|
+
const failing = parseFailing(output, hint);
|
|
106
|
+
const units = hint.includes("cargo") ? cargoUnits(output) : [];
|
|
107
|
+
return {
|
|
108
|
+
...(summary ? { counts: summary.counts, source: summary.source } : {}),
|
|
109
|
+
...(failing ? { failing } : {}),
|
|
110
|
+
...(units.length > 0 ? { units } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function baselineFrom(results, excluded = [], absent = []) {
|
|
114
|
+
return {
|
|
115
|
+
skipped: false,
|
|
116
|
+
excluded,
|
|
117
|
+
commands: Object.fromEntries(results.map((result) => [
|
|
118
|
+
result.command,
|
|
119
|
+
{
|
|
120
|
+
exitCode: result.exitCode,
|
|
121
|
+
...(result.counts ? { counts: result.counts } : {}),
|
|
122
|
+
...(result.failing ? { failing: result.failing } : {}),
|
|
123
|
+
...(result.source ? { source: result.source } : {}),
|
|
124
|
+
...(result.units ? { units: result.units } : {}),
|
|
125
|
+
...(result.errors ? { errors: result.errors } : {}),
|
|
126
|
+
...(absent.includes(result.command) ? { absent: true } : {}),
|
|
127
|
+
},
|
|
128
|
+
])),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function unusableBaseline(results, fix) {
|
|
132
|
+
return results.filter((result) => {
|
|
133
|
+
if (result.exitCode === -1 || (result.key === "test" && result.unrecognized))
|
|
134
|
+
return true;
|
|
135
|
+
if (result.counts)
|
|
136
|
+
return false;
|
|
137
|
+
if (result.key === "test")
|
|
138
|
+
return !(fix && result.exitCode !== 0);
|
|
139
|
+
return result.exitCode !== 0 && !result.errors;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
export function verdictOf(result, before, fix) {
|
|
143
|
+
if (result.exitCode === -1)
|
|
144
|
+
return "unfinished";
|
|
145
|
+
const red = result.exitCode !== 0 || (result.counts?.failed ?? 0) > 0;
|
|
146
|
+
if (result.key === "test" && !red && unread(result, before))
|
|
147
|
+
return "unknown";
|
|
148
|
+
if (fix && result.key === "test")
|
|
149
|
+
return red ? "mustPass" : "passed";
|
|
150
|
+
if (!before)
|
|
151
|
+
return red ? "noBaseline" : "passed";
|
|
152
|
+
const wasRed = before.exitCode !== 0 || (before.counts?.failed ?? 0) > 0;
|
|
153
|
+
if (!wasRed)
|
|
154
|
+
return red ? "regression" : "passed";
|
|
155
|
+
if (!red)
|
|
156
|
+
return "passed";
|
|
157
|
+
if (before.failing && result.failing) {
|
|
158
|
+
const known = new Set(before.failing);
|
|
159
|
+
if (result.failing.some((name) => !known.has(name)))
|
|
160
|
+
return "regression";
|
|
161
|
+
}
|
|
162
|
+
if (before.units && result.units && before.units.some((unit) => !result.units?.includes(unit))) {
|
|
163
|
+
return "regression";
|
|
164
|
+
}
|
|
165
|
+
if (!before.counts && before.errors && result.errors && result.key !== "test") {
|
|
166
|
+
const known = new Set(before.errors);
|
|
167
|
+
return result.errors.some((line) => !known.has(line)) ? "regression" : "preexisting";
|
|
168
|
+
}
|
|
169
|
+
if (!before.counts || !result.counts || before.source !== result.source)
|
|
170
|
+
return "uncomparable";
|
|
171
|
+
const worse = result.counts.failed > before.counts.failed || result.counts.passed < before.counts.passed;
|
|
172
|
+
return worse ? "regression" : "preexisting";
|
|
173
|
+
}
|
|
174
|
+
export function checkRegressions(results, baseline, fix, allowUnread = false) {
|
|
175
|
+
const checks = results.map((result) => {
|
|
176
|
+
const before = baseline?.commands[result.command];
|
|
177
|
+
return { ...result, verdict: verdictOf(result, before, fix), ...(before ? { before } : {}) };
|
|
178
|
+
});
|
|
179
|
+
const unread = checks.filter((check) => check.verdict === "unknown");
|
|
180
|
+
const regressions = checks.filter((check) => BLOCKING.has(check.verdict) && !(allowUnread && check.verdict === "unknown"));
|
|
181
|
+
return {
|
|
182
|
+
passed: regressions.length === 0,
|
|
183
|
+
checks,
|
|
184
|
+
regressions,
|
|
185
|
+
preexisting: checks.filter((check) => check.verdict === "preexisting"),
|
|
186
|
+
unread,
|
|
187
|
+
report: ["## Regression check", ...checks.map(describe)].join("\n\n"),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function unread(result, before) {
|
|
191
|
+
if (!result.counts)
|
|
192
|
+
return true;
|
|
193
|
+
if (!before?.counts || before.source === result.source)
|
|
194
|
+
return false;
|
|
195
|
+
return !(isReport(before.source) && isReport(result.source));
|
|
196
|
+
}
|
|
197
|
+
function isReport(source) {
|
|
198
|
+
return REPORT_SOURCES.includes(source);
|
|
199
|
+
}
|
|
200
|
+
export function blocking(verdict) {
|
|
201
|
+
return BLOCKING.has(verdict);
|
|
202
|
+
}
|
|
203
|
+
function describe(check) {
|
|
204
|
+
const head = `$ ${check.command} (${check.key}, exit ${check.exitCode}${countsText(check)})`;
|
|
205
|
+
if (check.verdict === "passed")
|
|
206
|
+
return head;
|
|
207
|
+
const output = check.output.slice(-OUTPUT_TAIL).trim();
|
|
208
|
+
return `${head} ${VERDICTS[check.verdict]}\n\n\`\`\`text\n${output}\n\`\`\``;
|
|
209
|
+
}
|
|
210
|
+
function countsText(check) {
|
|
211
|
+
const now = check.counts ? formatCounts(check.counts) : "";
|
|
212
|
+
const then = check.before?.counts ? formatCounts(check.before.counts) : "";
|
|
213
|
+
if (!now)
|
|
214
|
+
return "";
|
|
215
|
+
return then ? `; ${now}, before: ${then}` : `; ${now}`;
|
|
216
|
+
}
|
|
217
|
+
export function formatCounts(counts) {
|
|
218
|
+
return `${counts.passed} passed, ${counts.failed} failed, ${counts.skipped} skipped`;
|
|
219
|
+
}
|