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,339 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { UserError } from "../core/errors.js";
|
|
5
|
+
import { readTextIfExists, writeText } from "../core/fs.js";
|
|
6
|
+
import { gitPaths, isGitRepo } from "../core/git.js";
|
|
7
|
+
import { BAE_IGNORED } from "../core/gitignore.js";
|
|
8
|
+
import { asRecord, parseObject } from "../core/json.js";
|
|
9
|
+
import { scanFiles } from "../digest/walk.js";
|
|
10
|
+
import { t } from "../i18n/index.js";
|
|
11
|
+
import { inScope } from "../review/scope.js";
|
|
12
|
+
import { ignoreMatcher, untrackedFiles } from "./ignore-rules.js";
|
|
13
|
+
import { AGENT_FILES, BAE_SCRATCH, createdIsViolation, executedBy, GIT_FILES, isRunnerConfig, kindOf, repoProtected, sectionsOf, setupReferences, shadowCandidates, WATCHED_DIRS, } from "./protected-files.js";
|
|
14
|
+
export { isAcceptableKind, TASKS_DIR } from "./protected-files.js";
|
|
15
|
+
const LOG_HEADING = /^##[ \t]+(log|registro|bitácora|bitacora)[ \t]*$/i;
|
|
16
|
+
const SECTION_HEADING = /^##(?!#)/;
|
|
17
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/;
|
|
18
|
+
const MAX_PROTECTED_CHARS = 1_000_000;
|
|
19
|
+
const PACKAGE_KEYS = ["jest", "mocha", "ava", "eslintConfig", "prettier", "babel", "c8", "nyc"];
|
|
20
|
+
const ROOT_TOOLCHAIN = [
|
|
21
|
+
".npmrc",
|
|
22
|
+
".yarnrc",
|
|
23
|
+
".yarnrc.yml",
|
|
24
|
+
".envrc",
|
|
25
|
+
"bunfig.toml",
|
|
26
|
+
".pnpmfile.cjs",
|
|
27
|
+
];
|
|
28
|
+
export async function collectProtected(cwd, inputs = { suite: [], verification: [], own: [] }) {
|
|
29
|
+
const listing = await repoFiles(cwd);
|
|
30
|
+
const scripts = asRecord(parseObject((await readTextIfExists(absolute(cwd, "package.json"))) ?? "")?.scripts);
|
|
31
|
+
const suite = executedBy(inputs.suite, scripts);
|
|
32
|
+
const verification = executedBy(inputs.verification, scripts);
|
|
33
|
+
const own = (path) => inScope(path, inputs.own);
|
|
34
|
+
const candidates = [
|
|
35
|
+
...(await watchedFiles(cwd)),
|
|
36
|
+
...listing.filter(repoProtected),
|
|
37
|
+
...suite.files,
|
|
38
|
+
...verification.files.filter((path) => !own(path)),
|
|
39
|
+
...(await rootToolchain(cwd)),
|
|
40
|
+
...(await gitFiles(cwd)),
|
|
41
|
+
];
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const path of [...new Set(candidates)].sort()) {
|
|
44
|
+
const text = await readTextIfExists(absolute(cwd, path)).catch(() => undefined);
|
|
45
|
+
if (text === undefined || text.length > MAX_PROTECTED_CHARS || !tracked(path, text))
|
|
46
|
+
continue;
|
|
47
|
+
if (path === ".gitignore" && onlyBaeEntries(text))
|
|
48
|
+
continue;
|
|
49
|
+
result[path] = text;
|
|
50
|
+
if (!isRunnerConfig(path))
|
|
51
|
+
continue;
|
|
52
|
+
for (const reference of setupReferences(path, text)) {
|
|
53
|
+
const setup = await readTextIfExists(absolute(cwd, reference)).catch(() => undefined);
|
|
54
|
+
if (setup !== undefined && setup.length <= MAX_PROTECTED_CHARS)
|
|
55
|
+
result[reference] = setup;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const existing = new Set(listing);
|
|
59
|
+
const shadows = shadowCandidates({
|
|
60
|
+
files: [],
|
|
61
|
+
modules: [...suite.modules, ...verification.modules],
|
|
62
|
+
make: suite.make || verification.make,
|
|
63
|
+
}).filter((entry) => entry.endsWith("/")
|
|
64
|
+
? !listing.some((path) => path.startsWith(entry))
|
|
65
|
+
: !existing.has(entry) && !Object.hasOwn(result, entry));
|
|
66
|
+
return { protected: result, shadows };
|
|
67
|
+
}
|
|
68
|
+
export async function checkContract(cwd, state) {
|
|
69
|
+
const captured = state.protected;
|
|
70
|
+
const changes = [];
|
|
71
|
+
for (const [path, before] of Object.entries(captured)) {
|
|
72
|
+
const linked = (await lstat(absolute(cwd, path)).catch(() => undefined))?.isSymbolicLink();
|
|
73
|
+
const after = linked
|
|
74
|
+
? undefined
|
|
75
|
+
: await readTextIfExists(absolute(cwd, path)).catch(() => undefined);
|
|
76
|
+
const change = compare(path, kindOf(path, state.taskPath), before, after);
|
|
77
|
+
if (change)
|
|
78
|
+
changes.push({ ...change, fingerprint: fingerprint(after) });
|
|
79
|
+
}
|
|
80
|
+
const created = (path, kind) => ({
|
|
81
|
+
path,
|
|
82
|
+
kind,
|
|
83
|
+
change: "created",
|
|
84
|
+
detail: "",
|
|
85
|
+
restored: null,
|
|
86
|
+
fingerprint: fingerprint(path),
|
|
87
|
+
});
|
|
88
|
+
for (const path of await watchedFiles(cwd)) {
|
|
89
|
+
if (!Object.hasOwn(captured, path))
|
|
90
|
+
changes.push(created(path, kindOf(path, state.taskPath)));
|
|
91
|
+
}
|
|
92
|
+
for (const path of await gitFiles(cwd)) {
|
|
93
|
+
if (Object.hasOwn(captured, path))
|
|
94
|
+
continue;
|
|
95
|
+
if ((await readTextIfExists(absolute(cwd, path)).catch(() => undefined)) !== undefined) {
|
|
96
|
+
changes.push(created(path, "gitdir"));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const reported = new Set(changes.map((change) => change.path));
|
|
100
|
+
for (const path of await currentFiles(cwd, state.ignore)) {
|
|
101
|
+
if (reported.has(path) || Object.hasOwn(captured, path))
|
|
102
|
+
continue;
|
|
103
|
+
if (state.shadows.some((entry) => (entry.endsWith("/") ? path.startsWith(entry) : path === entry))) {
|
|
104
|
+
changes.push(created(path, "shadow"));
|
|
105
|
+
}
|
|
106
|
+
else if (createdIsViolation(path)) {
|
|
107
|
+
changes.push(created(path, kindOf(path, state.taskPath)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return changes;
|
|
111
|
+
}
|
|
112
|
+
export async function restoreContract(cwd, changes) {
|
|
113
|
+
const failed = [];
|
|
114
|
+
for (const change of changes) {
|
|
115
|
+
const target = absolute(cwd, change.path);
|
|
116
|
+
try {
|
|
117
|
+
await rm(target, { force: true, recursive: true });
|
|
118
|
+
if (change.restored !== null)
|
|
119
|
+
await writeText(target, change.restored);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
failed.push(change.path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (failed.length > 0)
|
|
126
|
+
throw new UserError(t("contract.restoreFailed", { files: failed.join(", ") }));
|
|
127
|
+
}
|
|
128
|
+
export function taskContract(text) {
|
|
129
|
+
const unix = text.replace(/\r\n/g, "\n");
|
|
130
|
+
const match = FRONTMATTER.exec(unix);
|
|
131
|
+
const lines = (match?.[1] ?? "").split("\n");
|
|
132
|
+
const status = lines.findIndex((line) => /^status[ \t]*:/.test(line));
|
|
133
|
+
const front = lines.filter((_, index) => index !== status).join("\n");
|
|
134
|
+
const body = match ? unix.slice(match[0].length) : unix;
|
|
135
|
+
return `${front}\n---\n${splitLog(body).kept.join("\n")}`.replace(/\s+$/, "");
|
|
136
|
+
}
|
|
137
|
+
export function spliceLog(captured, current) {
|
|
138
|
+
const log = splitLog(current.replace(/\r\n/g, "\n")).log;
|
|
139
|
+
const output = [];
|
|
140
|
+
let inLog = false;
|
|
141
|
+
let inserted = false;
|
|
142
|
+
let fenced = false;
|
|
143
|
+
for (const line of captured.replace(/\r\n/g, "\n").split("\n")) {
|
|
144
|
+
if (!fenced && SECTION_HEADING.test(line))
|
|
145
|
+
inLog = LOG_HEADING.test(line.trimEnd());
|
|
146
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
147
|
+
fenced = !fenced;
|
|
148
|
+
if (!inLog) {
|
|
149
|
+
output.push(line);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!inserted)
|
|
153
|
+
output.push(...log);
|
|
154
|
+
inserted = true;
|
|
155
|
+
}
|
|
156
|
+
if (!inserted && log.length > 0)
|
|
157
|
+
output.push("", ...log);
|
|
158
|
+
return output.join("\n");
|
|
159
|
+
}
|
|
160
|
+
function splitLog(body) {
|
|
161
|
+
const kept = [];
|
|
162
|
+
const log = [];
|
|
163
|
+
let inLog = false;
|
|
164
|
+
let fenced = false;
|
|
165
|
+
for (const line of body.split("\n")) {
|
|
166
|
+
if (!fenced && SECTION_HEADING.test(line))
|
|
167
|
+
inLog = LOG_HEADING.test(line.trimEnd());
|
|
168
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
169
|
+
fenced = !fenced;
|
|
170
|
+
(inLog ? log : kept).push(line);
|
|
171
|
+
}
|
|
172
|
+
return { kept, log };
|
|
173
|
+
}
|
|
174
|
+
export function withoutLog(text) {
|
|
175
|
+
return splitLog(unix(text)).kept.join("\n").trimEnd();
|
|
176
|
+
}
|
|
177
|
+
function compare(path, kind, before, after) {
|
|
178
|
+
const base = { path, kind, detail: "" };
|
|
179
|
+
if (after === undefined)
|
|
180
|
+
return { ...base, change: "deleted", restored: before };
|
|
181
|
+
if (kind === "task") {
|
|
182
|
+
if (taskContract(before) === taskContract(after))
|
|
183
|
+
return undefined;
|
|
184
|
+
return { ...base, change: "modified", restored: spliceLog(before, after) };
|
|
185
|
+
}
|
|
186
|
+
if (kind === "scripts") {
|
|
187
|
+
const keys = packageChanges(before, after);
|
|
188
|
+
if (keys.length === 0)
|
|
189
|
+
return undefined;
|
|
190
|
+
const detail = keys.join(", ");
|
|
191
|
+
return { ...base, detail, change: "modified", restored: restorePackage(before, after) };
|
|
192
|
+
}
|
|
193
|
+
const headers = sectionsOf(path);
|
|
194
|
+
if (kind === "runner" && headers) {
|
|
195
|
+
const changed = headers.filter((header) => (sectionOf(after, header) ?? "") !== (sectionOf(before, header) ?? ""));
|
|
196
|
+
if (changed.length === 0)
|
|
197
|
+
return undefined;
|
|
198
|
+
const restored = changed.reduce((text, header) => replaceSection(text, header, sectionOf(before, header) ?? ""), after);
|
|
199
|
+
return { ...base, detail: changed.join(", "), change: "modified", restored };
|
|
200
|
+
}
|
|
201
|
+
if (unix(before) === unix(after))
|
|
202
|
+
return undefined;
|
|
203
|
+
return { ...base, change: "modified", restored: before };
|
|
204
|
+
}
|
|
205
|
+
function packageChanges(before, after) {
|
|
206
|
+
const previous = parseObject(before) ?? {};
|
|
207
|
+
const current = parseObject(after);
|
|
208
|
+
if (!current)
|
|
209
|
+
return ["package.json"];
|
|
210
|
+
const scripts = asRecord(previous.scripts);
|
|
211
|
+
const now = asRecord(current.scripts);
|
|
212
|
+
const hooks = Object.keys(now).filter((key) => !(key in scripts) && /^(pre|post)./.test(key) && key.replace(/^(pre|post)/, "") in scripts);
|
|
213
|
+
return [
|
|
214
|
+
...Object.keys(scripts)
|
|
215
|
+
.filter((key) => now[key] !== scripts[key])
|
|
216
|
+
.map((key) => `scripts.${key}`),
|
|
217
|
+
...hooks.map((key) => `scripts.${key}`),
|
|
218
|
+
...PACKAGE_KEYS.filter((key) => key in previous && JSON.stringify(current[key]) !== JSON.stringify(previous[key])),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
function restorePackage(before, after) {
|
|
222
|
+
const previous = parseObject(before) ?? {};
|
|
223
|
+
const current = parseObject(after);
|
|
224
|
+
if (!current)
|
|
225
|
+
return before;
|
|
226
|
+
const restored = { ...current };
|
|
227
|
+
if (previous.scripts !== undefined) {
|
|
228
|
+
const scripts = asRecord(previous.scripts);
|
|
229
|
+
const kept = Object.entries(asRecord(current.scripts)).filter(([key]) => !(/^(pre|post)./.test(key) && key.replace(/^(pre|post)/, "") in scripts));
|
|
230
|
+
restored.scripts = { ...Object.fromEntries(kept), ...scripts };
|
|
231
|
+
}
|
|
232
|
+
for (const key of PACKAGE_KEYS.filter((name) => name in previous))
|
|
233
|
+
restored[key] = previous[key];
|
|
234
|
+
const indent = /\n([ \t]+)"/.exec(after)?.[1] ?? " ";
|
|
235
|
+
return `${JSON.stringify(restored, null, indent)}${after.endsWith("\n") ? "\n" : ""}`;
|
|
236
|
+
}
|
|
237
|
+
function sectionOf(text, prefix) {
|
|
238
|
+
const tables = tablesOf(text).filter((table) => matchesTable(table.name, prefix));
|
|
239
|
+
if (tables.length === 0)
|
|
240
|
+
return undefined;
|
|
241
|
+
return tables.map((table) => table.lines.join("\n").trimEnd()).join("\n\n");
|
|
242
|
+
}
|
|
243
|
+
function replaceSection(text, prefix, section) {
|
|
244
|
+
const kept = tablesOf(text)
|
|
245
|
+
.filter((table) => !matchesTable(table.name, prefix))
|
|
246
|
+
.map((table) => table.lines.join("\n").trimEnd())
|
|
247
|
+
.filter((block) => block !== "");
|
|
248
|
+
return `${[...kept, ...(section ? [section] : [])].join("\n\n")}\n`;
|
|
249
|
+
}
|
|
250
|
+
function tablesOf(text) {
|
|
251
|
+
const tables = [{ name: "", lines: [] }];
|
|
252
|
+
for (const line of unix(text).split("\n")) {
|
|
253
|
+
const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(line)?.[1];
|
|
254
|
+
if (header !== undefined)
|
|
255
|
+
tables.push({ name: header.trim(), lines: [] });
|
|
256
|
+
tables.at(-1)?.lines.push(line);
|
|
257
|
+
}
|
|
258
|
+
return tables;
|
|
259
|
+
}
|
|
260
|
+
function matchesTable(name, prefix) {
|
|
261
|
+
return (name === prefix || [".", "-", ":"].some((separator) => name.startsWith(`${prefix}${separator}`)));
|
|
262
|
+
}
|
|
263
|
+
async function walk(cwd, dir) {
|
|
264
|
+
if (BAE_SCRATCH.some((prefix) => `${dir}/`.startsWith(prefix)))
|
|
265
|
+
return [];
|
|
266
|
+
const entries = await listDir(absolute(cwd, dir));
|
|
267
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
268
|
+
const path = `${dir}/${entry.name}`;
|
|
269
|
+
if (entry.isDirectory())
|
|
270
|
+
return walk(cwd, path);
|
|
271
|
+
return entry.isFile() || entry.isSymbolicLink() ? [path] : [];
|
|
272
|
+
}));
|
|
273
|
+
return nested.flat();
|
|
274
|
+
}
|
|
275
|
+
async function listDir(path) {
|
|
276
|
+
try {
|
|
277
|
+
return await readdir(path, { withFileTypes: true });
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function absolute(cwd, path) {
|
|
284
|
+
return join(cwd, ...path.split("/"));
|
|
285
|
+
}
|
|
286
|
+
function fingerprint(text) {
|
|
287
|
+
return createHash("sha1")
|
|
288
|
+
.update(text ?? "deleted")
|
|
289
|
+
.digest("hex")
|
|
290
|
+
.slice(0, 12);
|
|
291
|
+
}
|
|
292
|
+
function unix(text) {
|
|
293
|
+
return text.replace(/\r\n/g, "\n");
|
|
294
|
+
}
|
|
295
|
+
function onlyBaeEntries(text) {
|
|
296
|
+
const lines = unix(text)
|
|
297
|
+
.split("\n")
|
|
298
|
+
.map((line) => line.trim())
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
return lines.every((line) => BAE_IGNORED.includes(line));
|
|
301
|
+
}
|
|
302
|
+
function tracked(path, text) {
|
|
303
|
+
const headers = sectionsOf(path);
|
|
304
|
+
return headers === undefined || headers.some((header) => sectionOf(text, header) !== undefined);
|
|
305
|
+
}
|
|
306
|
+
async function watchedFiles(cwd) {
|
|
307
|
+
const files = (await Promise.all(WATCHED_DIRS.map((dir) => walk(cwd, dir)))).flat();
|
|
308
|
+
const agents = await Promise.all(AGENT_FILES.map(async (path) => (await readTextIfExists(absolute(cwd, path)).catch(() => undefined)) === undefined
|
|
309
|
+
? []
|
|
310
|
+
: [path]));
|
|
311
|
+
return [...files, ...agents.flat()].filter((path) => !BAE_SCRATCH.some((prefix) => path.startsWith(prefix)));
|
|
312
|
+
}
|
|
313
|
+
async function gitFiles(cwd) {
|
|
314
|
+
const info = await stat(join(cwd, ".git")).catch(() => undefined);
|
|
315
|
+
return info?.isDirectory() ? GIT_FILES.map((name) => `.git/${name}`) : [];
|
|
316
|
+
}
|
|
317
|
+
async function repoFiles(cwd) {
|
|
318
|
+
if (await isGitRepo(cwd)) {
|
|
319
|
+
return (await gitPaths(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"])) ?? [];
|
|
320
|
+
}
|
|
321
|
+
return (await scanFiles(cwd)).files.map((file) => file.path);
|
|
322
|
+
}
|
|
323
|
+
async function currentFiles(cwd, ignore) {
|
|
324
|
+
const scanned = async () => (await scanFiles(cwd)).files.map((file) => file.path);
|
|
325
|
+
const root = await rootToolchain(cwd);
|
|
326
|
+
if (!(await isGitRepo(cwd)))
|
|
327
|
+
return [...(await scanned()), ...root];
|
|
328
|
+
const [tracked, untracked] = await Promise.all([
|
|
329
|
+
gitPaths(cwd, ["ls-files", "--cached"]),
|
|
330
|
+
untrackedFiles(cwd, ignoreMatcher(ignore)),
|
|
331
|
+
]);
|
|
332
|
+
if (!tracked || !untracked)
|
|
333
|
+
return [...(await scanned()), ...root];
|
|
334
|
+
return [...new Set([...tracked, ...untracked, ...root])];
|
|
335
|
+
}
|
|
336
|
+
async function rootToolchain(cwd) {
|
|
337
|
+
const found = await Promise.all(ROOT_TOOLCHAIN.map(async (name) => (await lstat(absolute(cwd, name)).catch(() => undefined)) ? [name] : []));
|
|
338
|
+
return found.flat();
|
|
339
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
3
|
+
import { flaggedFiles, git } from "../core/git.js";
|
|
4
|
+
import { asRecord, parseObject } from "../core/json.js";
|
|
5
|
+
import { t } from "../i18n/index.js";
|
|
6
|
+
import { capturedTask, formatFindings } from "../review/run.js";
|
|
7
|
+
import { inScope, scopePaths } from "../review/scope.js";
|
|
8
|
+
import { exclusionKeys } from "../review/tests.js";
|
|
9
|
+
import { checkContract, isAcceptableKind, restoreContract, } from "./contract.js";
|
|
10
|
+
import { accept, findingId } from "./findings.js";
|
|
11
|
+
import { runnerHint } from "./results.js";
|
|
12
|
+
import { expandCommand, TEST_RUNNERS } from "./runners.js";
|
|
13
|
+
const MESSAGES = {
|
|
14
|
+
task: "contract.task",
|
|
15
|
+
tasks: "contract.tasks",
|
|
16
|
+
bae: "contract.bae",
|
|
17
|
+
agents: "contract.agents",
|
|
18
|
+
gitignore: "contract.gitignore",
|
|
19
|
+
scripts: "contract.scripts",
|
|
20
|
+
runner: "contract.runner",
|
|
21
|
+
memory: "contract.memory",
|
|
22
|
+
git: "contract.git",
|
|
23
|
+
gitdir: "contract.gitdir",
|
|
24
|
+
toolchain: "contract.toolchain",
|
|
25
|
+
shadow: "contract.shadow",
|
|
26
|
+
};
|
|
27
|
+
const RUNNER_KEYS = ["jest", "mocha", "ava", "vitest"];
|
|
28
|
+
const RISKY_SETTINGS = [
|
|
29
|
+
/^\s*(script-shell|shell|node-options|ignore-scripts|yarnPath|preload)\s*[=:]/m,
|
|
30
|
+
/^\s*\[test\]/m,
|
|
31
|
+
/^\s*plugins\s*:/m,
|
|
32
|
+
];
|
|
33
|
+
export async function enforceContract(ctx, capture, acceptance) {
|
|
34
|
+
const changes = await checkContract(ctx.cwd, {
|
|
35
|
+
protected: capture.protected,
|
|
36
|
+
shadows: capture.shadows,
|
|
37
|
+
ignore: capture.ignore,
|
|
38
|
+
taskPath: capture.path,
|
|
39
|
+
});
|
|
40
|
+
const flags = await newIndexFlags(ctx.cwd, capture);
|
|
41
|
+
if (changes.length === 0 && flags.length === 0)
|
|
42
|
+
return { blocked: false, report: "", notes: [] };
|
|
43
|
+
const scope = scopePaths(capturedTask(capture));
|
|
44
|
+
const findings = await Promise.all(changes.map(async (change) => {
|
|
45
|
+
const listed = acceptable(change, scope);
|
|
46
|
+
const finding = contractFinding(change);
|
|
47
|
+
if (!listed) {
|
|
48
|
+
return settled(change, accept(acceptance, finding, listed));
|
|
49
|
+
}
|
|
50
|
+
const after = await readTextIfExists(join(ctx.cwd, ...change.path.split("/")));
|
|
51
|
+
const weaker = weakening(change.kind, capture.protected[change.path] ?? "", after);
|
|
52
|
+
if (!weaker) {
|
|
53
|
+
return {
|
|
54
|
+
...finding,
|
|
55
|
+
severity: "major",
|
|
56
|
+
message: `${finding.message} ${t("contract.scoped")}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return settled(change, accept(acceptance, { ...finding, message: `${finding.message} ${weaker}` }, true));
|
|
60
|
+
}));
|
|
61
|
+
const restore = changes.filter((_, index) => findings[index]?.severity === "blocker");
|
|
62
|
+
await restoreContract(ctx.cwd, restore);
|
|
63
|
+
if (flags.length > 0) {
|
|
64
|
+
await git(ctx.cwd, [
|
|
65
|
+
"update-index",
|
|
66
|
+
"--no-assume-unchanged",
|
|
67
|
+
"--no-skip-worktree",
|
|
68
|
+
"--",
|
|
69
|
+
...flags,
|
|
70
|
+
]);
|
|
71
|
+
findings.push({
|
|
72
|
+
severity: "blocker",
|
|
73
|
+
id: findingId("contract", "index", flags.join(",")),
|
|
74
|
+
message: t("contract.indexFlags", { files: flags.join(", ") }),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const blocked = restore.length > 0 || flags.length > 0;
|
|
78
|
+
const list = formatFindings(findings);
|
|
79
|
+
if (blocked)
|
|
80
|
+
ctx.prompter.warn(t("contract.failed"));
|
|
81
|
+
ctx.prompter.note(list, t("contract.title"));
|
|
82
|
+
return {
|
|
83
|
+
blocked,
|
|
84
|
+
report: `## ${t("contract.title")}\n\n${list}`,
|
|
85
|
+
notes: findings.filter((finding) => finding.severity === "major"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function weakening(kind, before, after) {
|
|
89
|
+
if (kind === "toolchain") {
|
|
90
|
+
const keys = RISKY_SETTINGS.filter((pattern) => pattern.test(after ?? "") && !pattern.test(before));
|
|
91
|
+
return keys.length > 0 ? t("contract.weakerToolchain") : undefined;
|
|
92
|
+
}
|
|
93
|
+
if (kind !== "scripts" && kind !== "runner")
|
|
94
|
+
return undefined;
|
|
95
|
+
const added = (text, previous) => exclusionKeys(text).filter((key) => !exclusionKeys(previous).includes(key));
|
|
96
|
+
if (kind === "runner") {
|
|
97
|
+
const keys = added(after ?? "", before);
|
|
98
|
+
return keys.length > 0 ? t("contract.weakerRunner", { keys: keys.join(", ") }) : undefined;
|
|
99
|
+
}
|
|
100
|
+
const old = parseObject(before) ?? {};
|
|
101
|
+
const now = parseObject(after ?? "") ?? {};
|
|
102
|
+
const scripts = asRecord(old.scripts);
|
|
103
|
+
const current = asRecord(now.scripts);
|
|
104
|
+
const dropped = Object.keys(scripts).flatMap((name) => {
|
|
105
|
+
if (typeof scripts[name] !== "string")
|
|
106
|
+
return [];
|
|
107
|
+
if (typeof current[name] !== "string")
|
|
108
|
+
return [`scripts.${name}`];
|
|
109
|
+
const tools = (map) => runnerHint(expandCommand(`npm run ${name}`, { scripts: map }));
|
|
110
|
+
const now = tools(current);
|
|
111
|
+
const tests = now.some((tool) => TEST_RUNNERS.has(tool));
|
|
112
|
+
const lost = tools(scripts).filter((tool) => !now.includes(tool) && !(tests && TEST_RUNNERS.has(tool)));
|
|
113
|
+
return lost.length > 0 ? [`scripts.${name} (${lost.join(", ")})`] : [];
|
|
114
|
+
});
|
|
115
|
+
const keys = RUNNER_KEYS.filter((key) => key in old).flatMap((key) => added(JSON.stringify(now[key] ?? ""), JSON.stringify(old[key])));
|
|
116
|
+
if (dropped.length > 0)
|
|
117
|
+
return t("contract.weakerScripts", { scripts: dropped.join(", ") });
|
|
118
|
+
return keys.length > 0 ? t("contract.weakerRunner", { keys: keys.join(", ") }) : undefined;
|
|
119
|
+
}
|
|
120
|
+
async function newIndexFlags(cwd, capture) {
|
|
121
|
+
if (!capture.git)
|
|
122
|
+
return [];
|
|
123
|
+
const flagged = (await flaggedFiles(cwd)) ?? [];
|
|
124
|
+
const known = new Set(capture.flagged);
|
|
125
|
+
return flagged.filter((path) => !known.has(path));
|
|
126
|
+
}
|
|
127
|
+
function contractFinding(change) {
|
|
128
|
+
return {
|
|
129
|
+
severity: "blocker",
|
|
130
|
+
id: findingId("contract", change.path, change.detail, change.fingerprint ?? ""),
|
|
131
|
+
file: change.path,
|
|
132
|
+
message: t(MESSAGES[change.kind], { path: change.path, detail: change.detail }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function settled(change, finding) {
|
|
136
|
+
const done = finding.severity !== "blocker"
|
|
137
|
+
? t("findings.accepted")
|
|
138
|
+
: t(change.change === "created" ? "contract.removed" : "contract.restored");
|
|
139
|
+
return { ...finding, message: `${finding.message} ${done}` };
|
|
140
|
+
}
|
|
141
|
+
function acceptable(change, scope) {
|
|
142
|
+
return isAcceptableKind(change.kind) && inScope(change.path, scope);
|
|
143
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function findingId(kind, ...parts) {
|
|
3
|
+
const hash = createHash("sha1")
|
|
4
|
+
.update([kind, ...parts].join("\u0000"))
|
|
5
|
+
.digest("hex");
|
|
6
|
+
return `${kind}-${hash.slice(0, 8)}`;
|
|
7
|
+
}
|
|
8
|
+
export function newAcceptance(ids = []) {
|
|
9
|
+
return { ids: new Set([...ids].map((id) => id.trim().toLowerCase())), accepted: [] };
|
|
10
|
+
}
|
|
11
|
+
export function accept(acceptance, finding, allowed) {
|
|
12
|
+
if (!allowed || !finding.id || !acceptance.ids.has(finding.id))
|
|
13
|
+
return finding;
|
|
14
|
+
const accepted = { ...finding, severity: "minor" };
|
|
15
|
+
acceptance.accepted.push(accepted);
|
|
16
|
+
return accepted;
|
|
17
|
+
}
|