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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/analyst/prompt.js +19 -0
  4. package/dist/analyst/retry.js +38 -0
  5. package/dist/artifacts/diff.js +47 -0
  6. package/dist/artifacts/lessons.js +32 -0
  7. package/dist/artifacts/merge.js +70 -0
  8. package/dist/artifacts/write.js +44 -0
  9. package/dist/backends/agent-cli.js +174 -0
  10. package/dist/backends/api.js +171 -0
  11. package/dist/backends/index.js +10 -0
  12. package/dist/backends/manual.js +52 -0
  13. package/dist/backends/sse.js +24 -0
  14. package/dist/backends/types.js +1 -0
  15. package/dist/bin.js +3 -0
  16. package/dist/cli.js +104 -0
  17. package/dist/commands/context.js +12 -0
  18. package/dist/commands/init.js +199 -0
  19. package/dist/commands/next.js +103 -0
  20. package/dist/commands/plan.js +71 -0
  21. package/dist/commands/replan.js +37 -0
  22. package/dist/commands/review.js +37 -0
  23. package/dist/commands/shared.js +40 -0
  24. package/dist/commands/status.js +95 -0
  25. package/dist/config/commands.js +85 -0
  26. package/dist/config/schema.js +43 -0
  27. package/dist/config/settings.js +8 -0
  28. package/dist/config/store.js +38 -0
  29. package/dist/core/bash.js +105 -0
  30. package/dist/core/errors.js +14 -0
  31. package/dist/core/fs.js +35 -0
  32. package/dist/core/git.js +104 -0
  33. package/dist/core/gitignore.js +14 -0
  34. package/dist/core/json.js +13 -0
  35. package/dist/core/paths.js +14 -0
  36. package/dist/core/process.js +56 -0
  37. package/dist/core/prompt-loader.js +27 -0
  38. package/dist/core/state.js +27 -0
  39. package/dist/core/which.js +24 -0
  40. package/dist/detect/agents.js +17 -0
  41. package/dist/detect/mode.js +11 -0
  42. package/dist/digest/baseline.js +192 -0
  43. package/dist/digest/budget.js +31 -0
  44. package/dist/digest/files.js +167 -0
  45. package/dist/digest/format.js +18 -0
  46. package/dist/digest/git.js +19 -0
  47. package/dist/digest/index.js +100 -0
  48. package/dist/digest/manifests.js +133 -0
  49. package/dist/digest/read.js +28 -0
  50. package/dist/digest/redact.js +37 -0
  51. package/dist/digest/render.js +138 -0
  52. package/dist/digest/select.js +116 -0
  53. package/dist/digest/todos.js +16 -0
  54. package/dist/digest/tree.js +55 -0
  55. package/dist/digest/walk.js +72 -0
  56. package/dist/gates/capture.js +145 -0
  57. package/dist/gates/contract.js +339 -0
  58. package/dist/gates/enforce.js +143 -0
  59. package/dist/gates/findings.js +17 -0
  60. package/dist/gates/gate.js +315 -0
  61. package/dist/gates/ignore-rules.js +104 -0
  62. package/dist/gates/protected-files.js +311 -0
  63. package/dist/gates/regression.js +219 -0
  64. package/dist/gates/reports.js +349 -0
  65. package/dist/gates/results.js +295 -0
  66. package/dist/gates/runners.js +189 -0
  67. package/dist/gates/state-guard.js +56 -0
  68. package/dist/i18n/en.js +288 -0
  69. package/dist/i18n/es.js +288 -0
  70. package/dist/i18n/index.js +18 -0
  71. package/dist/interview/adaptive.js +69 -0
  72. package/dist/interview/brief.js +27 -0
  73. package/dist/interview/questions.js +18 -0
  74. package/dist/interview/render.js +19 -0
  75. package/dist/interview/reply.js +34 -0
  76. package/dist/next/attempts.js +151 -0
  77. package/dist/next/start.js +179 -0
  78. package/dist/plan/continuation.js +40 -0
  79. package/dist/plan/evidence.js +239 -0
  80. package/dist/plan/filter.js +20 -0
  81. package/dist/plan/parser.js +283 -0
  82. package/dist/plan/prior.js +32 -0
  83. package/dist/plan/questions.js +48 -0
  84. package/dist/plan/repair.js +33 -0
  85. package/dist/plan/report.js +37 -0
  86. package/dist/plan/run.js +191 -0
  87. package/dist/prompts/analyst.md +101 -0
  88. package/dist/prompts/continue.md +11 -0
  89. package/dist/prompts/fix-format.md +13 -0
  90. package/dist/prompts/fix-paths.md +23 -0
  91. package/dist/prompts/lesson.md +18 -0
  92. package/dist/prompts/retry.md +9 -0
  93. package/dist/prompts/review.md +36 -0
  94. package/dist/prompts/task.md +9 -0
  95. package/dist/review/changes.js +177 -0
  96. package/dist/review/diff.js +115 -0
  97. package/dist/review/integrity.js +150 -0
  98. package/dist/review/mechanical.js +62 -0
  99. package/dist/review/parse.js +40 -0
  100. package/dist/review/reviewer.js +23 -0
  101. package/dist/review/run.js +134 -0
  102. package/dist/review/scope.js +55 -0
  103. package/dist/review/secrets.js +163 -0
  104. package/dist/review/snapshot.js +38 -0
  105. package/dist/review/tests.js +140 -0
  106. package/dist/tasks/attempts.js +71 -0
  107. package/dist/tasks/checks.js +181 -0
  108. package/dist/tasks/frontmatter.js +58 -0
  109. package/dist/tasks/graph.js +27 -0
  110. package/dist/tasks/handoff.js +62 -0
  111. package/dist/tasks/learn.js +128 -0
  112. package/dist/tasks/load.js +37 -0
  113. package/dist/tasks/metrics.js +40 -0
  114. package/dist/tasks/runs.js +12 -0
  115. package/dist/tasks/schema.js +96 -0
  116. package/dist/tasks/select.js +15 -0
  117. package/dist/tasks/shell-words.js +176 -0
  118. package/dist/tasks/status.js +13 -0
  119. package/dist/tasks/verify.js +181 -0
  120. package/dist/ui/clack.js +46 -0
  121. package/dist/ui/prompter.js +1 -0
  122. package/package.json +64 -0
@@ -0,0 +1,37 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { FormatError } from "../core/errors.js";
4
+ import { readTextIfExists } from "../core/fs.js";
5
+ import { parseTask, TASK_PATH, TASKS_DIR } from "./schema.js";
6
+ export async function loadTaskFiles(cwd) {
7
+ const names = await listMarkdown(join(cwd, ...TASKS_DIR.split("/")));
8
+ const loaded = await Promise.all(names.map((name) => loadOne(cwd, `${TASKS_DIR}/${name}`)));
9
+ return loaded
10
+ .filter((task) => task !== undefined)
11
+ .sort((a, b) => compareIds(a.id, b.id));
12
+ }
13
+ export function compareIds(a, b) {
14
+ return Number(a.slice(2)) - Number(b.slice(2)) || (a < b ? -1 : a > b ? 1 : 0);
15
+ }
16
+ async function loadOne(cwd, path) {
17
+ const id = TASK_PATH.exec(path)?.[1];
18
+ const text = await readTextIfExists(join(cwd, ...path.split("/")));
19
+ if (!id || text === undefined)
20
+ return undefined;
21
+ try {
22
+ return { path, id, text, task: parseTask(path, text) };
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof FormatError))
26
+ throw error;
27
+ return { path, id, text, error: error.message };
28
+ }
29
+ }
30
+ async function listMarkdown(dir) {
31
+ try {
32
+ return (await readdir(dir)).filter((name) => name.endsWith(".md")).sort();
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
@@ -0,0 +1,40 @@
1
+ import { readAttempts } from "./attempts.js";
2
+ export async function readMetrics(cwd, ids) {
3
+ const history = await Promise.all(ids.map(async (id) => [id, await readAttempts(cwd, id)]));
4
+ const tasks = new Map(history
5
+ .filter(([, attempts]) => attempts.length > 0)
6
+ .map(([id, attempts]) => [id, taskMetrics(id, attempts)]));
7
+ const all = history.flatMap(([, attempts]) => attempts);
8
+ const finished = [...tasks.values()].filter((task) => task.completedAt !== undefined);
9
+ return {
10
+ tasks,
11
+ attempts: all.length,
12
+ attempted: tasks.size,
13
+ doneOnFirst: [...tasks.values()].filter((task) => task.doneOnFirst).length,
14
+ regressionsCaught: all.filter((attempt) => attempt.stage === "regression").length,
15
+ done: finished.length,
16
+ doneDurationMs: finished.reduce((total, task) => total + task.durationMs, 0),
17
+ };
18
+ }
19
+ export function completionTimes(metrics) {
20
+ return new Map([...metrics.tasks.values()].flatMap((task) => task.completedAt === undefined ? [] : [[task.id, task.completedAt]]));
21
+ }
22
+ export function formatDuration(ms) {
23
+ const seconds = Math.round(ms / 1000);
24
+ if (seconds < 60)
25
+ return `${seconds}s`;
26
+ const minutes = Math.round(seconds / 60);
27
+ if (minutes < 60)
28
+ return `${minutes}m`;
29
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
30
+ }
31
+ function taskMetrics(id, attempts) {
32
+ const done = attempts.filter((attempt) => attempt.outcome === "done").at(-1);
33
+ return {
34
+ id,
35
+ attempts: attempts.length,
36
+ durationMs: attempts.reduce((total, attempt) => total + attempt.durationMs, 0),
37
+ doneOnFirst: attempts[0]?.outcome === "done",
38
+ ...(done ? { completedAt: Date.parse(done.startedAt) + done.durationMs } : {}),
39
+ };
40
+ }
@@ -0,0 +1,12 @@
1
+ import { join } from "node:path";
2
+ import { writeText } from "../core/fs.js";
3
+ import { repoState } from "../core/state.js";
4
+ export function runDir(cwd, id) {
5
+ return join(repoState(cwd), "runs", id);
6
+ }
7
+ export async function writeRunLog(cwd, id, content) {
8
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
9
+ const path = join(runDir(cwd, id), `${stamp}.md`);
10
+ await writeText(path, content);
11
+ return path;
12
+ }
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { FormatError } from "../core/errors.js";
3
+ import { trivialityProblems } from "./checks.js";
4
+ import { splitFrontmatter } from "./frontmatter.js";
5
+ import { logicalLines } from "./shell-words.js";
6
+ export const TASK_STATUSES = ["pending", "in_progress", "done", "blocked"];
7
+ export const TEST_POLICIES = ["required", "optional", "fix"];
8
+ export const TASK_ID = /^T-\d{3,}$/;
9
+ export const TASK_PATH = /^docs\/plan\/tasks\/(T-\d{3,})-[A-Za-z0-9._-]+\.md$/;
10
+ export const TASKS_DIR = "docs/plan/tasks";
11
+ const taskId = z.string().regex(TASK_ID, "must look like T-001");
12
+ export const taskMetaSchema = z.object({
13
+ id: taskId,
14
+ title: z.string().min(1),
15
+ status: z.enum(TASK_STATUSES),
16
+ phase: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/).transform(Number)]),
17
+ depends_on: z
18
+ .array(taskId)
19
+ .nullish()
20
+ .transform((value) => value ?? []),
21
+ size: z.enum(["S", "M", "L"]),
22
+ risk: z.enum(["low", "medium", "high"]),
23
+ tests: z.enum(TEST_POLICIES).default("optional"),
24
+ });
25
+ export const SECTIONS = {
26
+ goal: ["goal", "objetivo"],
27
+ context: ["context", "contexto"],
28
+ scope: ["scope", "alcance"],
29
+ steps: ["steps", "pasos"],
30
+ acceptance: ["acceptance criteria", "criterios de aceptación", "criterios de aceptacion"],
31
+ verification: ["verification", "verificación", "verificacion"],
32
+ risks: ["risks and notes", "riesgos y notas", "risks", "riesgos"],
33
+ log: ["log", "registro", "bitácora", "bitacora"],
34
+ };
35
+ const TRIVIALITY = {
36
+ masks: "hides failures (|| true, set +e); the block runs with set -euo pipefail and must fail when a check fails",
37
+ trivial: "runs nothing that checks the task; use the project's test runner, a linter or a check with an expected result (test -f, grep -q, curl -f)",
38
+ };
39
+ const COMMAND_BLOCK = /```(?:sh|bash|shell|console)[^\n]*\n([\s\S]*?)```/g;
40
+ export function parseTask(path, text) {
41
+ const frontmatter = readFrontmatter(path, text);
42
+ const result = taskMetaSchema.safeParse(frontmatter.data);
43
+ if (!result.success) {
44
+ const details = result.error.issues
45
+ .map((issue) => `${issue.path.join(".") || "frontmatter"}: ${issue.message}`)
46
+ .join("; ");
47
+ throw new FormatError(`${path}: invalid frontmatter (${details})`);
48
+ }
49
+ return { path, meta: result.data, body: frontmatter.body, text };
50
+ }
51
+ export function taskProblems(task) {
52
+ const missing = Object.keys(SECTIONS).filter((key) => sectionText(task.body, key) === undefined);
53
+ const problems = missing.map((key) => `${task.path}: missing section "${SECTIONS[key][0]}"`);
54
+ if (!missing.includes("verification") && verificationCommands(task.body).length === 0) {
55
+ problems.push(`${task.path}: Verification needs at least one command in a \`\`\`sh block`);
56
+ }
57
+ for (const problem of trivialityProblems(verificationScript(task.body))) {
58
+ problems.push(`${task.path}: Verification ${TRIVIALITY[problem.reason]}: ${problem.command}`);
59
+ }
60
+ return problems;
61
+ }
62
+ export function sectionText(body, key) {
63
+ return sectionTexts(body, key)[0];
64
+ }
65
+ export function sectionTexts(body, key) {
66
+ const aliases = SECTIONS[key];
67
+ const parts = body.split(/^##[ \t]+(.+?)[ \t]*$/m);
68
+ const found = [];
69
+ for (let index = 1; index < parts.length; index += 2) {
70
+ const heading = (parts[index] ?? "").trim().toLowerCase();
71
+ if (aliases.includes(heading))
72
+ found.push((parts[index + 1] ?? "").trim());
73
+ }
74
+ return found;
75
+ }
76
+ export function verificationScript(body) {
77
+ const section = sectionText(body, "verification") ?? "";
78
+ return Array.from(section.matchAll(COMMAND_BLOCK), (match) => match[1] ?? "")
79
+ .flatMap((block) => block.replace(/\r?\n$/, "").split(/\r?\n/))
80
+ .map((line) => line.replace(/^(\s*)\$\s+/, "$1"));
81
+ }
82
+ export function verificationCommands(body) {
83
+ return logicalLines(verificationScript(body));
84
+ }
85
+ function readFrontmatter(path, text) {
86
+ try {
87
+ const frontmatter = splitFrontmatter(text);
88
+ if (frontmatter)
89
+ return frontmatter;
90
+ }
91
+ catch (error) {
92
+ const details = error instanceof Error ? error.message.split("\n")[0] : String(error);
93
+ throw new FormatError(`${path}: frontmatter is not valid YAML (${details})`);
94
+ }
95
+ throw new FormatError(`${path}: missing YAML frontmatter`);
96
+ }
@@ -0,0 +1,15 @@
1
+ import { compareIds } from "./load.js";
2
+ export function orderTasks(tasks) {
3
+ return [...tasks].sort((a, b) => a.meta.phase - b.meta.phase || compareIds(a.meta.id, b.meta.id));
4
+ }
5
+ export function pickNext(tasks, exhausted = new Set()) {
6
+ const ordered = orderTasks(tasks);
7
+ const inProgress = ordered.filter((task) => task.meta.status === "in_progress");
8
+ return (inProgress.find((task) => !exhausted.has(task.meta.id)) ??
9
+ ordered.find((task) => task.meta.status === "pending" && waitingOn(task, tasks).length === 0) ??
10
+ inProgress[0]);
11
+ }
12
+ export function waitingOn(task, tasks) {
13
+ const done = new Set(tasks.filter((other) => other.meta.status === "done").map((other) => other.meta.id));
14
+ return task.meta.depends_on.filter((id) => !done.has(id));
15
+ }
@@ -0,0 +1,176 @@
1
+ const OPERATORS = ["&&", "||", ";", "|", "&"];
2
+ export function parseLine(line) {
3
+ const commands = [];
4
+ let words = [];
5
+ let word = "";
6
+ let text = "";
7
+ let quote;
8
+ let substitution = false;
9
+ let started = false;
10
+ const endWord = () => {
11
+ if (started)
12
+ words.push(word);
13
+ word = "";
14
+ started = false;
15
+ };
16
+ const endCommand = () => {
17
+ endWord();
18
+ if (words.length > 0)
19
+ commands.push({ words, text: text.trim() });
20
+ words = [];
21
+ text = "";
22
+ };
23
+ for (let index = 0; index < line.length; index++) {
24
+ const char = line[index] ?? "";
25
+ const rest = line.slice(index);
26
+ if (quote) {
27
+ if (char === quote)
28
+ quote = undefined;
29
+ else if (char === "\\" && quote === '"')
30
+ word += line[++index] ?? "";
31
+ else
32
+ word += char;
33
+ if (quote === '"' && (rest.startsWith("$(") || char === "`"))
34
+ substitution = true;
35
+ text += char;
36
+ continue;
37
+ }
38
+ if (char === "#" && !started)
39
+ break;
40
+ if (char === "'" || char === '"') {
41
+ quote = char;
42
+ started = true;
43
+ text += char;
44
+ continue;
45
+ }
46
+ if (char === "\\") {
47
+ word += line[++index] ?? "";
48
+ started = true;
49
+ text += char;
50
+ continue;
51
+ }
52
+ if (rest.startsWith("$(") || char === "`")
53
+ substitution = true;
54
+ const operator = OPERATORS.find((candidate) => rest.startsWith(candidate));
55
+ if (operator) {
56
+ endCommand();
57
+ index += operator.length - 1;
58
+ continue;
59
+ }
60
+ if (/\s/.test(char)) {
61
+ endWord();
62
+ text += char;
63
+ continue;
64
+ }
65
+ word += char;
66
+ started = true;
67
+ text += char;
68
+ }
69
+ endCommand();
70
+ return { commands, substitution };
71
+ }
72
+ const KEYWORDS = new Set([
73
+ "if",
74
+ "then",
75
+ "else",
76
+ "elif",
77
+ "while",
78
+ "until",
79
+ "do",
80
+ "done",
81
+ "fi",
82
+ "esac",
83
+ "!",
84
+ "{",
85
+ "}",
86
+ "(",
87
+ ")",
88
+ ";;",
89
+ "time",
90
+ ]);
91
+ const CASE_PATTERN = /^[^()=\s]*\)$/;
92
+ const HEREDOC = /(?<!<)<<-?(?!<)\s*(['"]?)([A-Za-z_]\w*)\1/;
93
+ export function program(command) {
94
+ let index = 0;
95
+ const words = command.words;
96
+ while (/^[A-Za-z_]\w*=/.test(words[index] ?? "") ||
97
+ KEYWORDS.has(words[index] ?? "") ||
98
+ (CASE_PATTERN.test(words[index] ?? "") && index < words.length - 1)) {
99
+ index++;
100
+ }
101
+ const [raw = "", ...args] = words.slice(index);
102
+ const first = raw.replace(/^\(+/, "");
103
+ if (first === "env")
104
+ return program({ words: args, text: command.text });
105
+ return { name: first, args };
106
+ }
107
+ export function heredocEnd(line) {
108
+ return HEREDOC.exec(unquoted(line))?.[2];
109
+ }
110
+ export function openQuote(text, start) {
111
+ let quote = start;
112
+ for (let index = 0; index < text.length; index++) {
113
+ const char = text[index] ?? "";
114
+ if (quote === "'") {
115
+ if (char === "'")
116
+ quote = undefined;
117
+ continue;
118
+ }
119
+ if (char === "\\") {
120
+ index++;
121
+ continue;
122
+ }
123
+ if (quote === '"') {
124
+ if (char === '"')
125
+ quote = undefined;
126
+ continue;
127
+ }
128
+ if (char === "#" && (index === 0 || /\s/.test(text[index - 1] ?? "")))
129
+ break;
130
+ if (char === "'" || char === '"')
131
+ quote = char;
132
+ }
133
+ return quote;
134
+ }
135
+ export function unquoted(line) {
136
+ return line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''").replace(/(^|\s)#.*$/, "$1");
137
+ }
138
+ export function logicalLines(lines) {
139
+ const joined = [];
140
+ let pending = "";
141
+ let quote;
142
+ let heredoc;
143
+ for (const raw of lines) {
144
+ if (heredoc !== undefined) {
145
+ if (raw.replace(/^\t+/, "").trimEnd() === heredoc)
146
+ heredoc = undefined;
147
+ continue;
148
+ }
149
+ const line = raw.replace(/\s+$/, "");
150
+ if (quote) {
151
+ pending += `\n${line}`;
152
+ quote = openQuote(line, quote);
153
+ if (quote)
154
+ continue;
155
+ }
156
+ else if (line.endsWith("\\") && !openQuote(line)) {
157
+ pending += `${line.slice(0, -1).trim()} `;
158
+ continue;
159
+ }
160
+ else {
161
+ pending = `${pending}${line.trim()}`;
162
+ quote = openQuote(line);
163
+ if (quote)
164
+ continue;
165
+ }
166
+ const done = pending.trim();
167
+ pending = "";
168
+ if (done !== "" && !done.startsWith("#")) {
169
+ joined.push(done);
170
+ heredoc = heredocEnd(done);
171
+ }
172
+ }
173
+ if (pending.trim())
174
+ joined.push(pending.trim());
175
+ return joined;
176
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from "node:path";
2
+ import { readTextIfExists, writeText } from "../core/fs.js";
3
+ import { setFrontmatterFields } from "./frontmatter.js";
4
+ import { withLogSection } from "./handoff.js";
5
+ export async function setTaskStatus(cwd, task, status) {
6
+ const path = join(cwd, ...task.path.split("/"));
7
+ const current = (await readTextIfExists(path)) ?? task.text;
8
+ const text = setFrontmatterFields(status === "in_progress" ? withLogSection(current) : current, {
9
+ status,
10
+ });
11
+ await writeText(path, text);
12
+ return { ...task, text, meta: { ...task.meta, status } };
13
+ }
@@ -0,0 +1,181 @@
1
+ import { runScript } from "../core/bash.js";
2
+ import { t } from "../i18n/index.js";
3
+ import { trivialityProblems } from "./checks.js";
4
+ import { heredocEnd, logicalLines, openQuote, parseLine, program, unquoted, } from "./shell-words.js";
5
+ const HEADER = [
6
+ "set -Eeuo pipefail",
7
+ "export CI=true",
8
+ `trap 'bae_status=$?; printf "\\nbae: failed with exit %s: %s\\n" "$bae_status" "$BASH_COMMAND" >&2' ERR`,
9
+ `bae_reuse() { printf '$ %s (%s)\\n' "$2" "$3"; return "$1"; }`,
10
+ `bae_ok() { [ "$1" -eq 0 ] || { printf '\\nbae: failed with exit %s: %s\\n' "$1" "$2" >&2; exit "$1"; }; }`,
11
+ ];
12
+ const FAILED_LINE = /^bae: failed with exit \d+: (.*)$/gm;
13
+ const OPENERS = /(^|[;&|]\s*)(if|while|until|for|case|select)\b/g;
14
+ const CLOSERS = /(^|[;&|]\s*)(fi|done|esac)\b/g;
15
+ const STATEFUL = new Set([
16
+ "cd",
17
+ "pushd",
18
+ "popd",
19
+ "export",
20
+ "unset",
21
+ "set",
22
+ "source",
23
+ ".",
24
+ "alias",
25
+ "shopt",
26
+ "umask",
27
+ "trap",
28
+ "declare",
29
+ "typeset",
30
+ "local",
31
+ "readonly",
32
+ "eval",
33
+ "exec",
34
+ "function",
35
+ ]);
36
+ const RULES = [
37
+ ["sudo", (command) => /(^|[\s;&|(`$])(sudo|doas)(\s|$)/.test(command)],
38
+ ["rm -rf", hasRecursiveForceRm],
39
+ [
40
+ "curl | sh",
41
+ (command) => /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|ksh|fish|python[\d.]*|node|perl|ruby|iex|invoke-expression)\b/i.test(command),
42
+ ],
43
+ ["git reset --hard", (command) => /\bgit\s+reset\b[^;&|]*--hard/.test(command)],
44
+ ["git clean -f", (command) => /\bgit\s+clean\b[^;&|]*\s-[a-zA-Z]*f/.test(command)],
45
+ ["git push", (command) => /\bgit\s+push\b/.test(command)],
46
+ [
47
+ "publish",
48
+ (command) => /\b(npm|pnpm|yarn)\s+(npm\s+)?publish\b|\bdocker\s+push\b/.test(command),
49
+ ],
50
+ [
51
+ "disk tools",
52
+ (command) => /\b(mkfs(\.\w+)?|fdisk|diskpart)\b|\bdd\b[^;&|]*\bof=\/dev\//.test(command),
53
+ ],
54
+ ["shutdown", (command) => /\b(shutdown|reboot|halt|poweroff)\b/.test(command)],
55
+ ["fork bomb", (command) => /:\(\)\s*\{/.test(command)],
56
+ [
57
+ "recursive delete",
58
+ (command) => /\b(rd|rmdir)\s+\/s\b|\bdel\b[^;&|]*\/s\b|remove-item\b(?=[^;&|]*-recurse)(?=[^;&|]*-force)/i.test(command),
59
+ ],
60
+ ];
61
+ export function findUnsafe(commands) {
62
+ return commands.flatMap((command) => {
63
+ const rule = RULES.find(([, matches]) => matches(command));
64
+ return rule ? [{ command, reason: rule[0] }] : [];
65
+ });
66
+ }
67
+ export async function runVerification(cwd, lines, options) {
68
+ const known = options.known ?? new Map();
69
+ const excused = options.excused ?? new Set();
70
+ const commands = logicalLines(lines);
71
+ const tolerated = commands.filter((command) => excused.has(command) && (known.get(command)?.exitCode ?? 0) !== 0);
72
+ const script = lines.join("\n");
73
+ const counted = commands.filter((command) => !(excused.has(command) && known.has(command)));
74
+ const onlyExcused = counted.length < commands.length &&
75
+ (counted.length === 0 || trivialityProblems(counted).some((item) => item.reason === "trivial"));
76
+ if (onlyExcused) {
77
+ return {
78
+ passed: false,
79
+ exitCode: 1,
80
+ output: t("verify.onlyExcused"),
81
+ script,
82
+ tolerated,
83
+ ...(commands[0] ? { failed: commands[0] } : {}),
84
+ };
85
+ }
86
+ const body = planLines(lines).flatMap((line) => {
87
+ const command = line.text.trim();
88
+ const result = line.reusable && line.check === command ? known.get(command) : undefined;
89
+ const check = line.check === undefined ? [] : [`bae_ok $? ${quote(line.check)}`];
90
+ if (!result)
91
+ return [line.text, ...check];
92
+ const allowed = excused.has(command);
93
+ const code = allowed ? 0 : result.exitCode;
94
+ const note = allowed
95
+ ? `exit ${result.exitCode}, preexisting: it already failed before the task and did not get worse`
96
+ : `exit ${result.exitCode}, from the regression check`;
97
+ return [`bae_reuse ${code} ${quote(command)} ${quote(note)}`, ...check];
98
+ });
99
+ options.onOutput?.(`${lines.map((line) => `$ ${line}`).join("\n")}\n`);
100
+ const result = await runScript(options.bash, [...HEADER, ...body, ""].join("\n"), {
101
+ cwd,
102
+ onOutput: options.onOutput,
103
+ ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
104
+ });
105
+ const failed = [...result.output.matchAll(FAILED_LINE)].at(-1)?.[1];
106
+ return {
107
+ passed: result.exitCode === 0,
108
+ exitCode: result.exitCode,
109
+ output: result.output,
110
+ script,
111
+ tolerated,
112
+ ...(failed ? { failed } : {}),
113
+ };
114
+ }
115
+ function planLines(lines) {
116
+ const planned = [];
117
+ let depth = 0;
118
+ let heredoc;
119
+ let quote;
120
+ let pending = "";
121
+ let stateful = false;
122
+ for (const line of lines) {
123
+ const entry = { text: line, reusable: false };
124
+ planned.push(entry);
125
+ if (heredoc !== undefined) {
126
+ if (line.replace(/^\t+/, "").trimEnd() === heredoc)
127
+ heredoc = undefined;
128
+ continue;
129
+ }
130
+ const trimmed = line.trim();
131
+ const inside = quote !== undefined;
132
+ if (!inside && pending === "" && (trimmed === "" || trimmed.startsWith("#")))
133
+ continue;
134
+ entry.reusable = !inside && pending === "" && depth === 0 && !stateful;
135
+ pending = pending ? `${pending}${inside ? "\n" : " "}${trimmed}` : trimmed;
136
+ quote = openQuote(line, quote);
137
+ if (quote)
138
+ continue;
139
+ if (!inside) {
140
+ depth += blockDelta(trimmed);
141
+ heredoc = heredocEnd(trimmed);
142
+ stateful ||= changesState(trimmed);
143
+ }
144
+ if (/(\\|&&|\|\||\|)$/.test(unquoted(trimmed).trimEnd()) || heredoc || depth > 0)
145
+ continue;
146
+ depth = Math.max(depth, 0);
147
+ entry.check = pending;
148
+ pending = "";
149
+ }
150
+ return planned;
151
+ }
152
+ function changesState(line) {
153
+ return parseLine(line).commands.some((command) => {
154
+ const { name } = program(command);
155
+ return (STATEFUL.has(name) ||
156
+ /^[A-Za-z_]\w*\s*\(\s*\)/.test(command.text) ||
157
+ (command.words.length > 0 && command.words.every((word) => /^[A-Za-z_]\w*=/.test(word))));
158
+ });
159
+ }
160
+ function blockDelta(line) {
161
+ const code = unquoted(line);
162
+ const opens = [...code.matchAll(OPENERS)].length + (/\{\s*$/.test(code) ? 1 : 0);
163
+ const closes = [...code.matchAll(CLOSERS)].length + (/^\s*\}/.test(code) ? 1 : 0);
164
+ return opens - closes;
165
+ }
166
+ function quote(text) {
167
+ return `'${text.replace(/'/g, "'\\''")}'`;
168
+ }
169
+ function hasRecursiveForceRm(command) {
170
+ return command.split(/[;&|]+/).some((segment) => {
171
+ const tokens = segment.trim().split(/\s+/);
172
+ const index = tokens.findIndex((token) => token === "rm" || token.endsWith("/rm"));
173
+ if (index === -1)
174
+ return false;
175
+ const flags = tokens.slice(index + 1).filter((token) => token.startsWith("-"));
176
+ const short = flags.filter((flag) => /^-[a-zA-Z]+$/.test(flag)).join("");
177
+ const recursive = /[rR]/.test(short) || flags.includes("--recursive");
178
+ const force = short.includes("f") || flags.includes("--force");
179
+ return recursive && force;
180
+ });
181
+ }
@@ -0,0 +1,46 @@
1
+ import * as clack from "@clack/prompts";
2
+ import { UserError } from "../core/errors.js";
3
+ import { t } from "../i18n/index.js";
4
+ export function createClackPrompter() {
5
+ return {
6
+ intro: (title) => clack.intro(title),
7
+ outro: (message) => clack.outro(message),
8
+ note: (message, title) => clack.note(message, title),
9
+ info: (message) => clack.log.info(message),
10
+ warn: (message) => clack.log.warn(message),
11
+ success: (message) => clack.log.success(message),
12
+ select: async (message, choices, initial) => unwrap(await clack.select({ message, options: choices, initialValue: initial })),
13
+ multiselect: async (message, choices, initial) => unwrap(await clack.multiselect({
14
+ message,
15
+ options: choices,
16
+ initialValues: initial,
17
+ required: true,
18
+ })),
19
+ text: async (message, placeholder) => unwrap(await clack.text({ message, placeholder })) ?? "",
20
+ confirm: async (message, initial) => unwrap(await clack.confirm({ message, initialValue: initial })),
21
+ spinner: async (message, task) => {
22
+ if (!process.stdout.isTTY)
23
+ return staticSpinner(message, task);
24
+ const spin = clack.spinner({ indicator: "timer" });
25
+ spin.start(message);
26
+ try {
27
+ const result = await task((update) => spin.message(update));
28
+ spin.stop(message);
29
+ return result;
30
+ }
31
+ catch (error) {
32
+ spin.error(message);
33
+ throw error;
34
+ }
35
+ },
36
+ };
37
+ }
38
+ function unwrap(value) {
39
+ if (clack.isCancel(value))
40
+ throw new UserError(t("ui.cancelled"));
41
+ return value;
42
+ }
43
+ async function staticSpinner(message, task) {
44
+ clack.log.step(message);
45
+ return task(() => { });
46
+ }
@@ -0,0 +1 @@
1
+ export {};