@ckeditor/ckeditor5-dev-agents 0.0.1 → 62.4.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.md +16 -0
- package/README.md +295 -3
- package/bin/ckeditor5-dev-agents.js +10 -0
- package/dist/agent/createagent.d.ts +37 -0
- package/dist/cli.d.ts +36 -0
- package/dist/cli.js +2186 -0
- package/dist/commands/resolvefinding.d.ts +72 -0
- package/dist/config/importmodules.d.ts +11 -0
- package/dist/config/loadconfig.d.ts +10 -0
- package/dist/config/loadtargets.d.ts +14 -0
- package/dist/config/loadtasks.d.ts +20 -0
- package/dist/config/selecttargets.d.ts +14 -0
- package/dist/constants.d.ts +20 -0
- package/dist/define.d.ts +33 -0
- package/dist/git/gitworkspace.d.ts +72 -0
- package/dist/github/githubclient.d.ts +39 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +79 -0
- package/dist/report/renderconsolesummary.d.ts +9 -0
- package/dist/report/renderpullrequest.d.ts +28 -0
- package/dist/report/renderreport.d.ts +18 -0
- package/dist/runner/changetracker.d.ts +37 -0
- package/dist/runner/createcontext.d.ts +22 -0
- package/dist/runner/fixunit.d.ts +64 -0
- package/dist/runner/runtarget.d.ts +49 -0
- package/dist/runner/runtask.d.ts +44 -0
- package/dist/runner/validatefindings.d.ts +24 -0
- package/dist/runtasks.d.ts +61 -0
- package/dist/state/decisions.d.ts +31 -0
- package/dist/state/statestore.d.ts +24 -0
- package/dist/types.d.ts +443 -0
- package/dist/utils/files.d.ts +12 -0
- package/dist/utils/findingschema.d.ts +12 -0
- package/dist/utils/githuburls.d.ts +7 -0
- package/dist/utils/strings.d.ts +17 -0
- package/dist/utils/text.d.ts +13 -0
- package/package.json +41 -2
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2186 @@
|
|
|
1
|
+
import { parseArgs, promisify } from "node:util";
|
|
2
|
+
import upath from "upath";
|
|
3
|
+
import { existsSync, globSync } from "node:fs";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { cp, glob, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { simpleGit } from "simple-git";
|
|
7
|
+
import { Octokit } from "@octokit/rest";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { ModelRuntime, SessionManager, SettingsManager, createAgentSession, createExtensionRuntime, defineTool, loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
|
|
11
|
+
import { parse, stringify } from "yaml";
|
|
12
|
+
import { execFile, spawn } from "node:child_process";
|
|
13
|
+
import { hash } from "node:crypto";
|
|
14
|
+
import { decodeHTMLStrict } from "entities";
|
|
15
|
+
import { posix } from "node:path";
|
|
16
|
+
//#region src/constants.ts
|
|
17
|
+
/**
|
|
18
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
19
|
+
* For licensing, see LICENSE.md.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The directory, relative to the target root, that holds the state of every task.
|
|
23
|
+
*/
|
|
24
|
+
const STATE_DIRECTORY = ".ai-tasks";
|
|
25
|
+
/**
|
|
26
|
+
* The prefix of report branches: `ai-tasks/<task>/<target>/<branch>`.
|
|
27
|
+
*/
|
|
28
|
+
const REPORT_BRANCH_PREFIX = "ai-tasks";
|
|
29
|
+
/**
|
|
30
|
+
* The tools an agent may use while judging. None of them can change a file.
|
|
31
|
+
*/
|
|
32
|
+
const READ_ONLY_TOOLS = [
|
|
33
|
+
"read",
|
|
34
|
+
"grep",
|
|
35
|
+
"find",
|
|
36
|
+
"ls"
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* Every built-in tool of the Pi agent. The agent ignores unknown tool names, so the configuration is checked against it.
|
|
40
|
+
*/
|
|
41
|
+
const AGENT_TOOLS = [
|
|
42
|
+
"read",
|
|
43
|
+
"bash",
|
|
44
|
+
"powershell",
|
|
45
|
+
"edit",
|
|
46
|
+
"write",
|
|
47
|
+
"grep",
|
|
48
|
+
"find",
|
|
49
|
+
"ls"
|
|
50
|
+
];
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/config/importmodules.ts
|
|
53
|
+
/**
|
|
54
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
55
|
+
* For licensing, see LICENSE.md.
|
|
56
|
+
*/
|
|
57
|
+
/**
|
|
58
|
+
* Imports the default export of every file matching the pattern in the directory, sorted by path.
|
|
59
|
+
*/
|
|
60
|
+
async function importDefaultExports(directory, pattern) {
|
|
61
|
+
const files = globSync(pattern, { cwd: directory });
|
|
62
|
+
const modules = [];
|
|
63
|
+
for (const file of files.map((file) => upath.normalize(file)).sort()) {
|
|
64
|
+
const { default: value } = await import(pathToFileURL(upath.join(directory, file)).href);
|
|
65
|
+
modules.push({
|
|
66
|
+
file,
|
|
67
|
+
value
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return modules;
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/config/loadtasks.ts
|
|
74
|
+
/**
|
|
75
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
76
|
+
* For licensing, see LICENSE.md.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Loads every task from `<tasksPath>/<id>/index.{js,mjs,ts,mts}`. Adding a task means adding a directory.
|
|
80
|
+
*/
|
|
81
|
+
async function loadTasks(tasksPath) {
|
|
82
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
83
|
+
for (const { file, value } of await importDefaultExports(tasksPath, "*/index.{js,mjs,ts,mts}")) {
|
|
84
|
+
const id = upath.dirname(file);
|
|
85
|
+
if (tasks.has(id)) throw new Error(`The "${id}" task has more than one \`index\` file.`);
|
|
86
|
+
validateTask(value, id);
|
|
87
|
+
tasks.set(id, {
|
|
88
|
+
task: value,
|
|
89
|
+
directory: upath.join(tasksPath, id)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return tasks;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Checks the rules of a task that its type cannot express.
|
|
96
|
+
*/
|
|
97
|
+
function validateTask(task, id) {
|
|
98
|
+
const invalid = (message) => /* @__PURE__ */ new Error(`The "${id}" task is invalid: ${message}`);
|
|
99
|
+
if (task?.id !== id) throw invalid(`the module must export by default a task whose \`id\` matches the directory name ("${id}").`);
|
|
100
|
+
if (!Array.isArray(task.ruleIds)) throw invalid("`ruleIds` must be an array of rule IDs.");
|
|
101
|
+
if (task.ruleIds.some((ruleId) => typeof ruleId !== "string" || ruleId === "" || ruleId.includes("|"))) throw invalid("every rule ID must be a non-empty string without \"|\".");
|
|
102
|
+
if (new Set(task.ruleIds).size !== task.ruleIds.length) throw invalid("`ruleIds` must be unique.");
|
|
103
|
+
if (!task.scope && !task.include?.length) throw invalid("it needs `scope()`, or `include` to work on the matching files.");
|
|
104
|
+
if (task.writes?.length && !task.fix) throw invalid("a task with `writes` must define `fix()`.");
|
|
105
|
+
if (task.fix && !task.writes?.length) throw invalid("a task with `fix()` must define `writes`: the files the fix may change.");
|
|
106
|
+
if (task.verify && !task.fix) throw invalid("`verify()` checks a fix, so it requires `fix()`.");
|
|
107
|
+
const unmatchablePatterns = (task.writes ?? []).filter((pattern) => /^\.{0,2}\//.test(pattern));
|
|
108
|
+
if (unmatchablePatterns.length) throw invalid(`\`writes\` must be globs relative to the target root, without a leading "./", "../" or "/". Fix: ${unmatchablePatterns.join(", ")}.`);
|
|
109
|
+
for (const field of ["maxUnits", "concurrency"]) {
|
|
110
|
+
const value = task[field];
|
|
111
|
+
if (value !== void 0 && !(Number.isInteger(value) && value >= 1)) throw invalid(`\`${field}\` must be an integer of 1 or more.`);
|
|
112
|
+
}
|
|
113
|
+
const unknownTools = [...task.agent?.judgeTools ?? [], ...task.agent?.fixTools ?? []].filter((tool) => !AGENT_TOOLS.includes(tool));
|
|
114
|
+
if (unknownTools.length) throw invalid(`the agent has no such tools: ${[...new Set(unknownTools)].join(", ")}. The available tools are: ${AGENT_TOOLS.join(", ")}.`);
|
|
115
|
+
const writingTools = (task.agent?.judgeTools ?? []).filter((tool) => !READ_ONLY_TOOLS.includes(tool));
|
|
116
|
+
if (writingTools.length) throw invalid(`\`agent.judgeTools\` may contain only read-only tools (${READ_ONLY_TOOLS.join(", ")}). Remove: ${writingTools.join(", ")}.`);
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/utils/files.ts
|
|
120
|
+
/**
|
|
121
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
122
|
+
* For licensing, see LICENSE.md.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* Runs the read and returns `null` instead of throwing when the file does not exist.
|
|
126
|
+
*/
|
|
127
|
+
async function readIfExists(read) {
|
|
128
|
+
try {
|
|
129
|
+
return await read();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.code === "ENOENT") return null;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether a normalized relative path points outside of the directory it is relative to.
|
|
137
|
+
*/
|
|
138
|
+
function isOutside(relativePath) {
|
|
139
|
+
return relativePath === ".." || relativePath.startsWith("../") || upath.isAbsolute(relativePath);
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/utils/strings.ts
|
|
143
|
+
/**
|
|
144
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
145
|
+
* For licensing, see LICENSE.md.
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* Compares strings by code units, independently of the locale, so sorted state files are the same on every machine.
|
|
149
|
+
*/
|
|
150
|
+
function compare(a, b) {
|
|
151
|
+
if (a === b) return 0;
|
|
152
|
+
return a < b ? -1 : 1;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Joins the lines of a text into one line, for example to show a multi-line error in a list.
|
|
156
|
+
*/
|
|
157
|
+
function inline(text) {
|
|
158
|
+
return text.replace(/\s*\n\s*/g, " ").trim();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Returns the message of a thrown value. Hooks of a task may throw anything, for example a string.
|
|
162
|
+
*/
|
|
163
|
+
function getErrorMessage(error) {
|
|
164
|
+
return error instanceof Error ? error.message : String(error);
|
|
165
|
+
}
|
|
166
|
+
function plural(count, word) {
|
|
167
|
+
return count === 1 ? word : `${word}s`;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/config/loadtargets.ts
|
|
171
|
+
/**
|
|
172
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
173
|
+
* For licensing, see LICENSE.md.
|
|
174
|
+
*/
|
|
175
|
+
const DEFAULT_MAX_UNITS = 100;
|
|
176
|
+
/**
|
|
177
|
+
* Loads every target from `<targetsPath>/<name>.{js,mjs,ts,mts}`. Adding a repository means adding a file.
|
|
178
|
+
*/
|
|
179
|
+
async function loadTargets(targetsPath, tasks) {
|
|
180
|
+
const targets = /* @__PURE__ */ new Map();
|
|
181
|
+
for (const { file, value } of await importDefaultExports(targetsPath, "*.{js,mjs,ts,mts}")) {
|
|
182
|
+
const name = file.replace(/\.(js|mjs|ts|mts)$/, "");
|
|
183
|
+
if (targets.has(name)) throw new Error(`The "${name}" target is defined in more than one file.`);
|
|
184
|
+
targets.set(name, normalizeTarget(value, {
|
|
185
|
+
name,
|
|
186
|
+
tasks
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
assertSeparateStates([...targets.values()]);
|
|
190
|
+
return targets;
|
|
191
|
+
}
|
|
192
|
+
function normalizeTarget(config, { name, tasks }) {
|
|
193
|
+
const invalid = (message) => /* @__PURE__ */ new Error(`The "${name}" target is invalid: ${message}`);
|
|
194
|
+
if (!config || !/^[\w.-]+\/[\w.-]+$/.test(config.slug)) throw invalid("the module must export by default a target with a `slug` like \"owner/repository\".");
|
|
195
|
+
const root = upath.normalize(config.root ?? ".");
|
|
196
|
+
if (isOutside(root)) throw invalid("`root` must be a path inside the repository.");
|
|
197
|
+
if (typeof config.tasks !== "object" || config.tasks === null || Array.isArray(config.tasks)) throw invalid("`tasks` must be an object that maps task names to their settings.");
|
|
198
|
+
const instances = /* @__PURE__ */ new Map();
|
|
199
|
+
for (const [instanceId, entry] of Object.entries(config.tasks)) {
|
|
200
|
+
if (instanceId === "" || /[|/\\]/.test(instanceId)) throw invalid(`the "${instanceId}" task name may not be empty or contain "|", "/" or "\\".`);
|
|
201
|
+
const loaded = tasks.get(entry.task ?? instanceId);
|
|
202
|
+
if (!loaded) throw invalid(`the "${entry.task ?? instanceId}" task does not exist.`);
|
|
203
|
+
const instance = resolveInstance(instanceId, entry, loaded);
|
|
204
|
+
try {
|
|
205
|
+
validateTask(instance.task, instanceId);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw invalid(getErrorMessage(error));
|
|
208
|
+
}
|
|
209
|
+
instances.set(instanceId, instance);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
name,
|
|
213
|
+
slug: config.slug,
|
|
214
|
+
root,
|
|
215
|
+
tasks: instances
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Throws when two targets would keep the state of a task in the same directory of the same repository. Each run would
|
|
220
|
+
* then drop the baseline and the findings of the other target.
|
|
221
|
+
*/
|
|
222
|
+
function assertSeparateStates(targets) {
|
|
223
|
+
const owners = /* @__PURE__ */ new Map();
|
|
224
|
+
for (const target of targets) for (const instanceId of target.tasks.keys()) {
|
|
225
|
+
const statePath = upath.join(target.root, STATE_DIRECTORY, instanceId);
|
|
226
|
+
const key = `${target.slug}:${statePath}`;
|
|
227
|
+
const owner = owners.get(key);
|
|
228
|
+
if (owner) throw new Error(`The "${owner}" and "${target.name}" targets keep the state of the "${instanceId}" task in the same directory of ${target.slug}: "${statePath}". Give them different roots, or name the task differently in one of them.`);
|
|
229
|
+
owners.set(key, target.name);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Puts the task under the name of the instance and applies the overrides of the target. An instance that runs a task
|
|
234
|
+
* under another name says so in its title, so its report pull request can be told apart from the one of the task.
|
|
235
|
+
*/
|
|
236
|
+
function resolveInstance(instanceId, entry, { task, directory }) {
|
|
237
|
+
return {
|
|
238
|
+
task: {
|
|
239
|
+
...task,
|
|
240
|
+
id: instanceId,
|
|
241
|
+
title: instanceId === task.id ? task.title : `${task.title} (${instanceId})`,
|
|
242
|
+
include: entry.include ?? task.include ?? [],
|
|
243
|
+
exclude: entry.exclude ?? task.exclude ?? [],
|
|
244
|
+
maxUnits: entry.maxUnits ?? task.maxUnits ?? DEFAULT_MAX_UNITS,
|
|
245
|
+
options: {
|
|
246
|
+
...task.defaultOptions,
|
|
247
|
+
...entry.options
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
directory,
|
|
251
|
+
enabled: entry.enabled !== false
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/config/loadconfig.ts
|
|
256
|
+
/**
|
|
257
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
258
|
+
* For licensing, see LICENSE.md.
|
|
259
|
+
*/
|
|
260
|
+
/**
|
|
261
|
+
* Loads the targets of the config directory, with their tasks. A directory without tasks or targets is almost always
|
|
262
|
+
* the wrong one, so it throws.
|
|
263
|
+
*/
|
|
264
|
+
async function loadConfig(configPath) {
|
|
265
|
+
const tasks = await loadTasks(upath.join(configPath, "tasks"));
|
|
266
|
+
const targets = await loadTargets(upath.join(configPath, "targets"), tasks);
|
|
267
|
+
for (const [name, items] of [["tasks", tasks], ["targets", targets]]) if (!items.size) throw new Error(`No ${name} found in "${upath.join(configPath, name)}". Check that "--config" points to the directory with "tasks/" and "targets/".`);
|
|
268
|
+
return targets;
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/config/selecttargets.ts
|
|
272
|
+
/**
|
|
273
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
274
|
+
* For licensing, see LICENSE.md.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* Returns the targets to run in a checkout of the `originSlug` repository: the named ones, or every target of
|
|
278
|
+
* the repository. A checkout belongs to one repository, so only the targets of that repository can run in it.
|
|
279
|
+
*/
|
|
280
|
+
function selectTargets(targets, { originSlug, names, taskIds }) {
|
|
281
|
+
for (const name of names) if (!targets.has(name)) throw new Error(`The "${name}" target does not exist. Available targets: ${[...targets.keys()].join(", ")}.`);
|
|
282
|
+
const origin = originSlug ?? "an unknown repository";
|
|
283
|
+
const selected = names.length ? names.map((name) => targets.get(name)) : [...targets.values()].filter((target) => target.slug === originSlug);
|
|
284
|
+
for (const target of selected) if (target.slug !== originSlug) throw new Error(`The "${target.name}" target is for "${target.slug}", but the checkout is a clone of ${origin}.`);
|
|
285
|
+
if (!selected.length) throw new Error(`No target is configured for the checkout, which is a clone of ${origin}.`);
|
|
286
|
+
for (const taskId of taskIds) if (!selected.some((target) => target.tasks.has(taskId))) throw new Error(`The "${taskId}" task is not configured in any of the selected targets.`);
|
|
287
|
+
return selected;
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/git/gitworkspace.ts
|
|
291
|
+
/**
|
|
292
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
293
|
+
* For licensing, see LICENSE.md.
|
|
294
|
+
*/
|
|
295
|
+
/**
|
|
296
|
+
* Opens the checkout that contains `cwd`. The checkout is prepared by whoever runs the framework: cloned, with
|
|
297
|
+
* the dependencies installed, and with the base branch checked out. The `author` is needed only to commit.
|
|
298
|
+
*/
|
|
299
|
+
async function openWorkspace({ cwd, author }) {
|
|
300
|
+
const gitOptions = { config: [...author ? [`user.name=${author.name}`, `user.email=${author.email}`] : [], "commit.gpgsign=false"] };
|
|
301
|
+
let topLevel;
|
|
302
|
+
try {
|
|
303
|
+
topLevel = (await simpleGit({ baseDir: cwd }).revparse(["--show-toplevel"])).trim();
|
|
304
|
+
} catch (error) {
|
|
305
|
+
throw new Error(`"${cwd}" is not inside a git repository.`, { cause: error });
|
|
306
|
+
}
|
|
307
|
+
const path = upath.normalize(await realpath(topLevel));
|
|
308
|
+
const git = simpleGit({
|
|
309
|
+
...gitOptions,
|
|
310
|
+
baseDir: path
|
|
311
|
+
});
|
|
312
|
+
const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
313
|
+
const baseCommit = (await git.revparse(["HEAD"])).trim();
|
|
314
|
+
let reportBranch;
|
|
315
|
+
let needsForcePush = false;
|
|
316
|
+
return {
|
|
317
|
+
path,
|
|
318
|
+
branch,
|
|
319
|
+
originSlug: await getOriginSlug(git),
|
|
320
|
+
async assertPublishable(paths) {
|
|
321
|
+
if (branch === "HEAD") throw new Error("Publishing needs a checked-out branch, but HEAD is detached. Check out the base branch first.");
|
|
322
|
+
if ((await git.raw(["rev-parse", "--is-shallow-repository"])).trim() === "true") throw new Error("Publishing needs the full history of the checkout, but it is shallow. Configure the checkout in CI to fetch the whole history.");
|
|
323
|
+
if ((await git.raw(["status", "--porcelain"])).trim()) throw new Error("Publishing needs a clean working tree, but it has uncommitted changes. Commit or remove them first.");
|
|
324
|
+
const ignored = paths.length ? (await git.raw([
|
|
325
|
+
"check-ignore",
|
|
326
|
+
"--",
|
|
327
|
+
...paths
|
|
328
|
+
])).trim() : "";
|
|
329
|
+
if (ignored) throw new Error(`Publishing commits the state of the tasks, but a \`.gitignore\` file ignores it: ${ignored.split("\n").join(", ")}. Stop ignoring these paths first.`);
|
|
330
|
+
},
|
|
331
|
+
async checkoutReportBranch({ name, continueExisting }) {
|
|
332
|
+
const existsRemotely = Boolean((await git.raw([
|
|
333
|
+
"ls-remote",
|
|
334
|
+
"--heads",
|
|
335
|
+
"origin",
|
|
336
|
+
name
|
|
337
|
+
])).trim());
|
|
338
|
+
reportBranch = name;
|
|
339
|
+
if (!continueExisting || !existsRemotely) {
|
|
340
|
+
needsForcePush = existsRemotely;
|
|
341
|
+
await git.checkout([
|
|
342
|
+
"-B",
|
|
343
|
+
name,
|
|
344
|
+
baseCommit
|
|
345
|
+
]);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
needsForcePush = false;
|
|
349
|
+
await git.fetch("origin", `+refs/heads/${name}:refs/remotes/origin/${name}`);
|
|
350
|
+
await git.checkout([
|
|
351
|
+
"-B",
|
|
352
|
+
name,
|
|
353
|
+
`origin/${name}`
|
|
354
|
+
]);
|
|
355
|
+
try {
|
|
356
|
+
await git.merge([
|
|
357
|
+
"--no-edit",
|
|
358
|
+
"-m",
|
|
359
|
+
`Merge ${branch} into ${name}.`,
|
|
360
|
+
baseCommit
|
|
361
|
+
]);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
await git.merge(["--abort"]).catch(() => {});
|
|
364
|
+
throw new Error(`Merging "${branch}" into "${name}" failed. Resolve the conflict in the report pull request by hand. Details: ${error.message}`, { cause: error });
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
async commit(paths, message) {
|
|
368
|
+
await git.add([
|
|
369
|
+
"--all",
|
|
370
|
+
"--",
|
|
371
|
+
...paths
|
|
372
|
+
]);
|
|
373
|
+
if (!(await git.diff([
|
|
374
|
+
"--cached",
|
|
375
|
+
"--name-only",
|
|
376
|
+
"--",
|
|
377
|
+
...paths
|
|
378
|
+
])).trim()) return false;
|
|
379
|
+
await git.raw([
|
|
380
|
+
"commit",
|
|
381
|
+
"--quiet",
|
|
382
|
+
"-m",
|
|
383
|
+
message,
|
|
384
|
+
"--",
|
|
385
|
+
...paths
|
|
386
|
+
]);
|
|
387
|
+
return true;
|
|
388
|
+
},
|
|
389
|
+
async push() {
|
|
390
|
+
await git.push("origin", reportBranch, needsForcePush ? ["--force"] : []);
|
|
391
|
+
needsForcePush = false;
|
|
392
|
+
},
|
|
393
|
+
async returnToBase() {
|
|
394
|
+
await git.raw([
|
|
395
|
+
"reset",
|
|
396
|
+
"--hard",
|
|
397
|
+
"--quiet"
|
|
398
|
+
]);
|
|
399
|
+
await git.raw([
|
|
400
|
+
"clean",
|
|
401
|
+
"-fd",
|
|
402
|
+
"--quiet"
|
|
403
|
+
]);
|
|
404
|
+
await git.checkout(branch);
|
|
405
|
+
},
|
|
406
|
+
async refExists(ref) {
|
|
407
|
+
return succeeds(() => git.raw([
|
|
408
|
+
"rev-parse",
|
|
409
|
+
"--verify",
|
|
410
|
+
`${ref}^{commit}`
|
|
411
|
+
]));
|
|
412
|
+
},
|
|
413
|
+
async hasChanges(paths) {
|
|
414
|
+
return Boolean((await git.raw([
|
|
415
|
+
"status",
|
|
416
|
+
"--porcelain",
|
|
417
|
+
"--",
|
|
418
|
+
...paths
|
|
419
|
+
])).trim());
|
|
420
|
+
},
|
|
421
|
+
async restore(paths, ref) {
|
|
422
|
+
for (const file of paths) if (await succeeds(() => git.raw([
|
|
423
|
+
"cat-file",
|
|
424
|
+
"-e",
|
|
425
|
+
`${ref}:${file}`
|
|
426
|
+
]))) await git.raw([
|
|
427
|
+
"checkout",
|
|
428
|
+
ref,
|
|
429
|
+
"--",
|
|
430
|
+
file
|
|
431
|
+
]);
|
|
432
|
+
else {
|
|
433
|
+
await git.raw([
|
|
434
|
+
"rm",
|
|
435
|
+
"--cached",
|
|
436
|
+
"--quiet",
|
|
437
|
+
"--ignore-unmatch",
|
|
438
|
+
"--",
|
|
439
|
+
file
|
|
440
|
+
]);
|
|
441
|
+
await rm(upath.join(path, file), { force: true });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Returns the GitHub slug of `origin`. Supports the SSH (`git@github.com:owner/repo.git`) and the HTTPS
|
|
448
|
+
* (`https://github.com/owner/repo`) forms.
|
|
449
|
+
*/
|
|
450
|
+
async function getOriginSlug(git) {
|
|
451
|
+
return ((await git.getRemotes(true)).find((remote) => remote.name === "origin")?.refs.fetch ?? "").match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/)?.[1];
|
|
452
|
+
}
|
|
453
|
+
async function succeeds(command) {
|
|
454
|
+
try {
|
|
455
|
+
await command();
|
|
456
|
+
return true;
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
//#endregion
|
|
462
|
+
//#region src/github/githubclient.ts
|
|
463
|
+
/**
|
|
464
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
465
|
+
* For licensing, see LICENSE.md.
|
|
466
|
+
*/
|
|
467
|
+
const API_VERSION = "2026-03-10";
|
|
468
|
+
const REQUEST_TIMEOUT = 3e4;
|
|
469
|
+
/**
|
|
470
|
+
* Creates a minimal client of the GitHub REST API with the calls that report pull requests need.
|
|
471
|
+
*/
|
|
472
|
+
function createGitHubClient(token) {
|
|
473
|
+
const octokit = new Octokit({ auth: token });
|
|
474
|
+
octokit.hook.before("request", (options) => {
|
|
475
|
+
options.headers["x-github-api-version"] = API_VERSION;
|
|
476
|
+
options.request = {
|
|
477
|
+
...options.request,
|
|
478
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
octokit.hook.error("request", (error) => {
|
|
482
|
+
const { status, request } = error;
|
|
483
|
+
if (!status || !request) throw error;
|
|
484
|
+
const message = `GitHub API request "${request.method} ${request.url}" failed with status ${status}: ${error.message}`;
|
|
485
|
+
throw new Error(message, { cause: error });
|
|
486
|
+
});
|
|
487
|
+
return {
|
|
488
|
+
async findOpenPullRequest({ slug, head }) {
|
|
489
|
+
const [owner, repo] = splitSlug(slug);
|
|
490
|
+
const { data } = await octokit.pulls.list({
|
|
491
|
+
owner,
|
|
492
|
+
repo,
|
|
493
|
+
state: "open",
|
|
494
|
+
head: `${owner}:${head}`
|
|
495
|
+
});
|
|
496
|
+
return data[0] ?? null;
|
|
497
|
+
},
|
|
498
|
+
async createPullRequest({ slug, head, base, title, body }) {
|
|
499
|
+
const [owner, repo] = splitSlug(slug);
|
|
500
|
+
return (await octokit.pulls.create({
|
|
501
|
+
owner,
|
|
502
|
+
repo,
|
|
503
|
+
head,
|
|
504
|
+
base,
|
|
505
|
+
title,
|
|
506
|
+
body
|
|
507
|
+
})).data;
|
|
508
|
+
},
|
|
509
|
+
async updatePullRequest({ slug, number, body }) {
|
|
510
|
+
const [owner, repo] = splitSlug(slug);
|
|
511
|
+
return (await octokit.pulls.update({
|
|
512
|
+
owner,
|
|
513
|
+
repo,
|
|
514
|
+
pull_number: number,
|
|
515
|
+
body
|
|
516
|
+
})).data;
|
|
517
|
+
},
|
|
518
|
+
async createComment({ slug, number, body }) {
|
|
519
|
+
const [owner, repo] = splitSlug(slug);
|
|
520
|
+
return (await octokit.issues.createComment({
|
|
521
|
+
owner,
|
|
522
|
+
repo,
|
|
523
|
+
issue_number: number,
|
|
524
|
+
body
|
|
525
|
+
})).data;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function splitSlug(slug) {
|
|
530
|
+
const [owner, repo] = slug.split("/");
|
|
531
|
+
return [owner, repo];
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/utils/githuburls.ts
|
|
535
|
+
/**
|
|
536
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
537
|
+
* For licensing, see LICENSE.md.
|
|
538
|
+
*/
|
|
539
|
+
function getBlobUrl(target, branch, path) {
|
|
540
|
+
return `https://github.com/${target.slug}/blob/${encodePath(branch)}/${encodePath(upath.join(target.root, path))}`;
|
|
541
|
+
}
|
|
542
|
+
function getHistoryUrl(target, branch, path) {
|
|
543
|
+
return `https://github.com/${target.slug}/commits/${encodePath(branch)}/${encodePath(upath.join(target.root, path))}`;
|
|
544
|
+
}
|
|
545
|
+
function encodePath(path) {
|
|
546
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
547
|
+
}
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/report/renderpullrequest.ts
|
|
550
|
+
/**
|
|
551
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
552
|
+
* For licensing, see LICENSE.md.
|
|
553
|
+
*/
|
|
554
|
+
/**
|
|
555
|
+
* Renders the description of the report pull request. It is rewritten on every run that pushes,
|
|
556
|
+
* so it always shows the current number of findings.
|
|
557
|
+
*/
|
|
558
|
+
function renderPullRequestBody({ task, target, branch, reportBranch, open, fixed }) {
|
|
559
|
+
const reportUrl = getBlobUrl(target, reportBranch, `${STATE_DIRECTORY}/${task.id}/report.md`);
|
|
560
|
+
return [
|
|
561
|
+
`This pull request is maintained by the "${task.title}" task (\`${task.id}\`) of \`@ckeditor/ckeditor5-dev-agents\`. It covers \`${target.name}\` at \`${branch}\`. Every run pushes new commits here and comments with what it found.`,
|
|
562
|
+
"",
|
|
563
|
+
`**${open}** open ${plural(open, "finding")}, **${fixed}** fixed in this pull request. See [report.md](${reportUrl}) for the findings and how to review them.`,
|
|
564
|
+
"",
|
|
565
|
+
`Merge this pull request to record the state on \`${branch}\`. Until then, the next runs continue this branch, so no unit is judged twice.`,
|
|
566
|
+
""
|
|
567
|
+
].join("\n");
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Renders the comment posted after every run that pushed something.
|
|
571
|
+
*/
|
|
572
|
+
function renderRunComment({ today, summary, buildUrl }) {
|
|
573
|
+
return [
|
|
574
|
+
buildUrl ? `### [Run](${buildUrl}) on ${today}` : `### Run on ${today}`,
|
|
575
|
+
"",
|
|
576
|
+
...renderSummary(summary)
|
|
577
|
+
].join("\n");
|
|
578
|
+
}
|
|
579
|
+
function renderSummary(summary) {
|
|
580
|
+
const lines = [
|
|
581
|
+
"| Units | Changed | Judged | Left | New findings | Already open | Rejected | Fixed | Discarded | Files changed | Errors | Open | Cost |",
|
|
582
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
583
|
+
"| " + [
|
|
584
|
+
summary.units,
|
|
585
|
+
summary.changed,
|
|
586
|
+
summary.judged,
|
|
587
|
+
summary.deferred,
|
|
588
|
+
summary.newFindings,
|
|
589
|
+
summary.alreadyOpen,
|
|
590
|
+
summary.rejected,
|
|
591
|
+
summary.fixed,
|
|
592
|
+
summary.discarded,
|
|
593
|
+
summary.changedFiles,
|
|
594
|
+
summary.errors,
|
|
595
|
+
summary.open,
|
|
596
|
+
`$${summary.cost.toFixed(2)}`
|
|
597
|
+
].join(" | ") + " |",
|
|
598
|
+
""
|
|
599
|
+
];
|
|
600
|
+
const problems = summary.problems.map(({ unit, message }) => `* ${unit ? `\`${unit}\`: ` : ""}${inline(message)}`);
|
|
601
|
+
if (problems.length) lines.push("#### Problems", "", ...problems, "");
|
|
602
|
+
return lines;
|
|
603
|
+
}
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/agent/createagent.ts
|
|
606
|
+
/**
|
|
607
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
608
|
+
* For licensing, see LICENSE.md.
|
|
609
|
+
*/
|
|
610
|
+
const RESULT_TOOL_NAME = "submit_result";
|
|
611
|
+
const AGENT_DIRECTORY = upath.join(tmpdir(), "ckeditor5-dev-agents");
|
|
612
|
+
const DEFAULT_TIMEOUT = 1800;
|
|
613
|
+
const MAX_RETRIES = 3;
|
|
614
|
+
const MAX_ARGUMENTS_LENGTH = 120;
|
|
615
|
+
const modelRuntimes = /* @__PURE__ */ new Map();
|
|
616
|
+
/**
|
|
617
|
+
* Creates the `agent` of one phase of a task. Every `run()` starts a new in-memory Pi session that is configured only
|
|
618
|
+
* by the task: its instructions, skills, tools and model. The API key comes from the task config (`agent.apiKey`),
|
|
619
|
+
* never from the environment of the process.
|
|
620
|
+
*/
|
|
621
|
+
function createAgent(options) {
|
|
622
|
+
const { taskId, config, taskDirectory, cwd, tools, usage, log, warn } = options;
|
|
623
|
+
let resources;
|
|
624
|
+
const loadResources = async (agentConfig) => createResourceLoader({
|
|
625
|
+
systemPrompt: await getInstructions(agentConfig, taskDirectory),
|
|
626
|
+
skills: getSkills(agentConfig, taskDirectory, (message) => {
|
|
627
|
+
log(`[agent] ${message}`);
|
|
628
|
+
warn(message);
|
|
629
|
+
})
|
|
630
|
+
});
|
|
631
|
+
return { async run({ prompt, outputSchema }) {
|
|
632
|
+
if (!config) throw new Error(`The "${taskId}" task calls \`agent.run()\`, but it does not configure \`agent\`.`);
|
|
633
|
+
if (!config.apiKey) throw new Error(`The "${taskId}" task calls \`agent.run()\`, but \`agent.apiKey\` is empty. Provide the API key of the "${config.model.provider}" provider in the task config.`);
|
|
634
|
+
const modelRuntime = await getModelRuntime(config.model.provider, config.apiKey);
|
|
635
|
+
const model = modelRuntime.getModel(config.model.provider, config.model.id);
|
|
636
|
+
if (!model) throw new Error(`The "${config.model.provider}/${config.model.id}" model is not available.`);
|
|
637
|
+
let result;
|
|
638
|
+
const resultTool = outputSchema && defineTool({
|
|
639
|
+
name: RESULT_TOOL_NAME,
|
|
640
|
+
label: "Submit result",
|
|
641
|
+
description: "Submits the result of the task. Call it exactly once, when you are done.",
|
|
642
|
+
parameters: outputSchema,
|
|
643
|
+
async execute(_toolCallId, params) {
|
|
644
|
+
result = params;
|
|
645
|
+
return {
|
|
646
|
+
content: [{
|
|
647
|
+
type: "text",
|
|
648
|
+
text: "The result was received. You are done."
|
|
649
|
+
}],
|
|
650
|
+
details: void 0
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
const { session } = await createAgentSession({
|
|
655
|
+
cwd,
|
|
656
|
+
agentDir: AGENT_DIRECTORY,
|
|
657
|
+
model,
|
|
658
|
+
modelRuntime,
|
|
659
|
+
thinkingLevel: config.thinkingLevel,
|
|
660
|
+
tools: resultTool ? [...tools, RESULT_TOOL_NAME] : [...tools],
|
|
661
|
+
customTools: resultTool ? [resultTool] : [],
|
|
662
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
663
|
+
settingsManager: SettingsManager.inMemory({
|
|
664
|
+
enableInstallTelemetry: false,
|
|
665
|
+
retry: {
|
|
666
|
+
enabled: true,
|
|
667
|
+
maxRetries: MAX_RETRIES
|
|
668
|
+
}
|
|
669
|
+
}),
|
|
670
|
+
resourceLoader: await (resources ??= loadResources(config))
|
|
671
|
+
});
|
|
672
|
+
const unsubscribe = session.subscribe((event) => {
|
|
673
|
+
if (event.type === "tool_execution_start" && event.toolName !== RESULT_TOOL_NAME) log(`[agent] ${event.toolName} ${summarizeArguments(event.args)}`);
|
|
674
|
+
});
|
|
675
|
+
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
676
|
+
let hasTimedOut = false;
|
|
677
|
+
const timer = setTimeout(() => {
|
|
678
|
+
hasTimedOut = true;
|
|
679
|
+
session.abort().catch(() => {});
|
|
680
|
+
}, timeout * 1e3);
|
|
681
|
+
const sendPrompt = async (text) => {
|
|
682
|
+
try {
|
|
683
|
+
await session.prompt(text);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (!hasTimedOut) throw error;
|
|
686
|
+
}
|
|
687
|
+
if (hasTimedOut) throw new Error(`The agent did not finish within ${timeout} seconds.`);
|
|
688
|
+
};
|
|
689
|
+
try {
|
|
690
|
+
await sendPrompt(resultTool ? `${prompt}\n\nWhen you are done, call the \`${RESULT_TOOL_NAME}\` tool exactly once with the result. Do not write the result as text.` : prompt);
|
|
691
|
+
if (resultTool && result === void 0) await sendPrompt(`You have not called the \`${RESULT_TOOL_NAME}\` tool. Call it now with the result.`);
|
|
692
|
+
const lastMessage = session.messages.findLast((message) => message.role === "assistant");
|
|
693
|
+
if (lastMessage?.stopReason === "error") throw new Error(`The agent failed: ${lastMessage.errorMessage ?? "unknown error"}`);
|
|
694
|
+
if (resultTool && result === void 0) throw new Error(`The agent did not return a result through the \`${RESULT_TOOL_NAME}\` tool.`);
|
|
695
|
+
return {
|
|
696
|
+
output: result,
|
|
697
|
+
text: session.getLastAssistantText() ?? ""
|
|
698
|
+
};
|
|
699
|
+
} finally {
|
|
700
|
+
const stats = session.getSessionStats();
|
|
701
|
+
usage.cost += stats.cost;
|
|
702
|
+
usage.tokens += stats.tokens.total;
|
|
703
|
+
clearTimeout(timer);
|
|
704
|
+
unsubscribe();
|
|
705
|
+
session.dispose();
|
|
706
|
+
}
|
|
707
|
+
} };
|
|
708
|
+
}
|
|
709
|
+
function getModelRuntime(provider, apiKey) {
|
|
710
|
+
const key = `${provider}\0${apiKey}`;
|
|
711
|
+
if (!modelRuntimes.has(key)) modelRuntimes.set(key, createModelRuntime(provider, apiKey));
|
|
712
|
+
return modelRuntimes.get(key);
|
|
713
|
+
}
|
|
714
|
+
async function createModelRuntime(provider, apiKey) {
|
|
715
|
+
const modelRuntime = await ModelRuntime.create({
|
|
716
|
+
credentials: new InMemoryCredentialStore(),
|
|
717
|
+
modelsPath: null,
|
|
718
|
+
allowModelNetwork: false
|
|
719
|
+
});
|
|
720
|
+
await modelRuntime.setRuntimeApiKey(provider, apiKey);
|
|
721
|
+
return modelRuntime;
|
|
722
|
+
}
|
|
723
|
+
async function getInstructions(config, taskDirectory) {
|
|
724
|
+
if (config.instructions) return config.instructions;
|
|
725
|
+
if (config.instructionsPath) return readFile(upath.resolve(taskDirectory, config.instructionsPath), "utf8");
|
|
726
|
+
}
|
|
727
|
+
function getSkills(config, taskDirectory, warn) {
|
|
728
|
+
return (config.skills ?? []).flatMap((directory) => {
|
|
729
|
+
const { skills, diagnostics } = loadSkillsFromDir({
|
|
730
|
+
dir: upath.resolve(taskDirectory, directory),
|
|
731
|
+
source: "task"
|
|
732
|
+
});
|
|
733
|
+
for (const diagnostic of diagnostics) warn(`A skill could not be loaded: ${diagnostic.message}`);
|
|
734
|
+
return skills;
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Gives the agent only the system prompt and the skills of the task, so a run behaves the same on every machine,
|
|
739
|
+
* whatever user and project resources (`AGENTS.md`, `~/.pi`, extensions, prompt templates) it has.
|
|
740
|
+
*/
|
|
741
|
+
function createResourceLoader({ systemPrompt, skills }) {
|
|
742
|
+
return {
|
|
743
|
+
getExtensions: () => ({
|
|
744
|
+
extensions: [],
|
|
745
|
+
errors: [],
|
|
746
|
+
runtime: createExtensionRuntime()
|
|
747
|
+
}),
|
|
748
|
+
getSkills: () => ({
|
|
749
|
+
skills,
|
|
750
|
+
diagnostics: []
|
|
751
|
+
}),
|
|
752
|
+
getPrompts: () => ({
|
|
753
|
+
prompts: [],
|
|
754
|
+
diagnostics: []
|
|
755
|
+
}),
|
|
756
|
+
getThemes: () => ({
|
|
757
|
+
themes: [],
|
|
758
|
+
diagnostics: []
|
|
759
|
+
}),
|
|
760
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
761
|
+
getSystemPrompt: () => systemPrompt,
|
|
762
|
+
getSystemPromptSource: () => void 0,
|
|
763
|
+
getAppendSystemPrompt: () => [],
|
|
764
|
+
getAppendSystemPromptSources: () => [],
|
|
765
|
+
extendResources: () => {},
|
|
766
|
+
reload: async () => {}
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function summarizeArguments(args) {
|
|
770
|
+
const text = String(JSON.stringify(args));
|
|
771
|
+
return text.length > MAX_ARGUMENTS_LENGTH ? `${text.slice(0, 117)}...` : text;
|
|
772
|
+
}
|
|
773
|
+
//#endregion
|
|
774
|
+
//#region src/state/decisions.ts
|
|
775
|
+
/**
|
|
776
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
777
|
+
* For licensing, see LICENSE.md.
|
|
778
|
+
*/
|
|
779
|
+
const REQUIRED_FIELDS = [
|
|
780
|
+
"unit",
|
|
781
|
+
"rule",
|
|
782
|
+
"fragment",
|
|
783
|
+
"reason"
|
|
784
|
+
];
|
|
785
|
+
const OPTIONAL_FIELDS = ["discriminator", "finding"];
|
|
786
|
+
/**
|
|
787
|
+
* Loads every decision file (`*.yml`) from the directory. A file that cannot be used does not stop
|
|
788
|
+
* the run. It is skipped and reported as a warning, so the finding it was meant to reject is reported again.
|
|
789
|
+
*/
|
|
790
|
+
async function loadDecisions(decisionsPath) {
|
|
791
|
+
const files = globSync("*.yml", { cwd: decisionsPath });
|
|
792
|
+
const decisions = [];
|
|
793
|
+
const warnings = [];
|
|
794
|
+
for (const file of files.sort()) try {
|
|
795
|
+
const content = await readFile(upath.join(decisionsPath, file), "utf8");
|
|
796
|
+
decisions.push({
|
|
797
|
+
file,
|
|
798
|
+
...parseDecision(content)
|
|
799
|
+
});
|
|
800
|
+
} catch (error) {
|
|
801
|
+
warnings.push(`The "decisions/${file}" decision file was skipped: ${error.message.split("\n")[0]}`);
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
decisions,
|
|
805
|
+
warnings
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Returns the decisions about the same claim as the finding: the same unit, rule and discriminator.
|
|
810
|
+
*/
|
|
811
|
+
function findDecisions(decisions, finding) {
|
|
812
|
+
return decisions.filter((decision) => {
|
|
813
|
+
return decision.unit === finding.unit && decision.rule === finding.ruleId && decision.discriminator === finding.discriminator;
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Returns the decisions about the unit, each marked as expired or not. The harness hands them to `judge()`,
|
|
818
|
+
* so a model reads the human reasoning, including the reasoning about content that has changed since.
|
|
819
|
+
*/
|
|
820
|
+
function getPriorDecisions(decisions, unitKey, fragment) {
|
|
821
|
+
return decisions.filter((decision) => decision.unit === unitKey).map(({ unit, rule, discriminator, fragment: decisionFragment, finding, reason }) => ({
|
|
822
|
+
unit,
|
|
823
|
+
rule,
|
|
824
|
+
discriminator,
|
|
825
|
+
fragment: decisionFragment,
|
|
826
|
+
finding,
|
|
827
|
+
reason,
|
|
828
|
+
expired: decisionFragment !== fragment
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
831
|
+
function parseDecision(content) {
|
|
832
|
+
const decision = parse(content, { schema: "failsafe" });
|
|
833
|
+
if (!decision || typeof decision !== "object" || Array.isArray(decision)) throw new Error("It must contain a single YAML mapping.");
|
|
834
|
+
const fields = decision;
|
|
835
|
+
for (const field of REQUIRED_FIELDS) if (typeof fields[field] !== "string" || fields[field].trim() === "") throw new Error(`The "${field}" field must be a non-empty string.`);
|
|
836
|
+
for (const field of OPTIONAL_FIELDS) if (fields[field] !== void 0 && typeof fields[field] !== "string") throw new Error(`The "${field}" field must be a string.`);
|
|
837
|
+
const text = (field) => (fields[field] ?? "").trim();
|
|
838
|
+
return {
|
|
839
|
+
unit: text("unit"),
|
|
840
|
+
rule: text("rule"),
|
|
841
|
+
discriminator: text("discriminator"),
|
|
842
|
+
fragment: text("fragment"),
|
|
843
|
+
finding: text("finding"),
|
|
844
|
+
reason: text("reason")
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Renders the decision file that rejects the finding.
|
|
849
|
+
*/
|
|
850
|
+
function renderDecision(finding, reason) {
|
|
851
|
+
return stringify({
|
|
852
|
+
unit: finding.unit,
|
|
853
|
+
rule: finding.ruleId,
|
|
854
|
+
discriminator: finding.discriminator || void 0,
|
|
855
|
+
fragment: finding.fragment,
|
|
856
|
+
finding: finding.detail,
|
|
857
|
+
reason
|
|
858
|
+
}, { lineWidth: 0 });
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Returns a readable file name, without the extension, for the decision about the finding, for example
|
|
862
|
+
* `b2-docs-features-tables`.
|
|
863
|
+
*/
|
|
864
|
+
function getDecisionFileName({ ruleId, unit, discriminator }) {
|
|
865
|
+
return [
|
|
866
|
+
ruleId,
|
|
867
|
+
unit.replace(/\.md$/, ""),
|
|
868
|
+
discriminator
|
|
869
|
+
].filter(Boolean).join("-").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/state/statestore.ts
|
|
873
|
+
/**
|
|
874
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
875
|
+
* For licensing, see LICENSE.md.
|
|
876
|
+
*/
|
|
877
|
+
/**
|
|
878
|
+
* Reads the state of one task: `baseline.json`, `open.json` and `decisions/*.yml`. Missing files mean
|
|
879
|
+
* an empty state, which is what the first run of a task sees.
|
|
880
|
+
*/
|
|
881
|
+
async function readTaskState(taskStatePath) {
|
|
882
|
+
const baseline = await readJson(taskStatePath, "baseline.json", {});
|
|
883
|
+
const open = await readJson(taskStatePath, "open.json", []);
|
|
884
|
+
const { decisions, warnings } = await loadDecisions(upath.join(taskStatePath, "decisions"));
|
|
885
|
+
if (!baseline || typeof baseline !== "object" || Array.isArray(baseline)) throw new Error("The \"baseline.json\" file must contain an object.");
|
|
886
|
+
if (!Array.isArray(open)) throw new Error("The \"open.json\" file must contain an array.");
|
|
887
|
+
return {
|
|
888
|
+
baseline,
|
|
889
|
+
open,
|
|
890
|
+
decisions,
|
|
891
|
+
warnings
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Writes the state of one task. Keys and findings are sorted, so the diff shows only what really changed.
|
|
896
|
+
*/
|
|
897
|
+
async function writeTaskState(taskStatePath, { baseline, open, report }) {
|
|
898
|
+
const sortedBaseline = Object.fromEntries(Object.keys(baseline).sort().map((key) => [key, baseline[key]]));
|
|
899
|
+
const sortedOpen = open.toSorted((a, b) => compare(a.fingerprint, b.fingerprint));
|
|
900
|
+
await mkdir(taskStatePath, { recursive: true });
|
|
901
|
+
await writeFile(upath.join(taskStatePath, "baseline.json"), toJson(sortedBaseline));
|
|
902
|
+
await writeFile(upath.join(taskStatePath, "open.json"), toJson(sortedOpen));
|
|
903
|
+
await writeFile(upath.join(taskStatePath, "report.md"), report);
|
|
904
|
+
}
|
|
905
|
+
async function readJson(directory, file, defaultValue) {
|
|
906
|
+
const content = await readIfExists(() => readFile(upath.join(directory, file), "utf8"));
|
|
907
|
+
if (content === null) return defaultValue;
|
|
908
|
+
try {
|
|
909
|
+
return JSON.parse(content);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
throw new Error(`The "${file}" file is not valid JSON: ${error.message}`, { cause: error });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function toJson(value) {
|
|
915
|
+
return JSON.stringify(value, null, " ") + "\n";
|
|
916
|
+
}
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/report/renderreport.ts
|
|
919
|
+
/**
|
|
920
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
921
|
+
* For licensing, see LICENSE.md.
|
|
922
|
+
*/
|
|
923
|
+
/**
|
|
924
|
+
* Renders `report.md` of one task from its findings. The file is regenerated on every run,
|
|
925
|
+
* so `open.json` stays the only source of truth.
|
|
926
|
+
*/
|
|
927
|
+
function renderReport({ task, target, branch, open }) {
|
|
928
|
+
const taskStatePath = `${STATE_DIRECTORY}/${task.id}`;
|
|
929
|
+
const sorted = open.toSorted((a, b) => compare(a.fingerprint, b.fingerprint));
|
|
930
|
+
const fixed = sorted.filter((finding) => finding.fix);
|
|
931
|
+
const unresolved = sorted.filter((finding) => !finding.fix);
|
|
932
|
+
const lines = [
|
|
933
|
+
"<!-- Generated from open.json by @ckeditor/ckeditor5-dev-agents. Edit open.json instead. -->",
|
|
934
|
+
"",
|
|
935
|
+
`# ${task.title}`,
|
|
936
|
+
""
|
|
937
|
+
];
|
|
938
|
+
if (!open.length) {
|
|
939
|
+
lines.push("There are no findings.", "");
|
|
940
|
+
return lines.join("\n");
|
|
941
|
+
}
|
|
942
|
+
lines.push(`**${unresolved.length}** open ${plural(unresolved.length, "finding")}, **${fixed.length}** fixed in the report pull request.`, "", "Resolve each finding in one of these ways, using the ID in its heading:", "", "* Keep it: keep the proposed fix, or fix it yourself in the report pull request.", `* Reject it: check out the report branch and run \`ckeditor5-dev-agents reject <id> --reason "…"\`. It records why the finding is wrong in \`${upath.join(target.root, taskStatePath)}/decisions/\`, removes it from \`open.json\`, and reverts its fix. The decision holds until the judged content changes. Commit and push the result.`, "* Dismiss it: `ckeditor5-dev-agents dismiss <id>` does the same without a decision, so it may be reported again once the judged content changes.", "", "Reverting a fix by hand, without rejecting the finding, means the task finds the problem and fixes it again on a later run.", "");
|
|
943
|
+
const renderFinding = (finding) => {
|
|
944
|
+
const findingLines = [
|
|
945
|
+
`#### ${finding.discriminator ? `${finding.ruleId}: ${finding.discriminator}` : finding.ruleId} \`[${finding.id}]\``,
|
|
946
|
+
"",
|
|
947
|
+
`<!-- fp: ${finding.fingerprint} -->`,
|
|
948
|
+
"",
|
|
949
|
+
finding.detail,
|
|
950
|
+
""
|
|
951
|
+
];
|
|
952
|
+
if (finding.fix) {
|
|
953
|
+
const files = finding.fix.files.map((file) => `\`${file}\``).join(", ");
|
|
954
|
+
findingLines.push(`**Proposed fix:** ${files}. Review it in the diff.`, "");
|
|
955
|
+
}
|
|
956
|
+
if (finding.previousDecision) {
|
|
957
|
+
const { file, fragment, finding: claim, reason } = finding.previousDecision;
|
|
958
|
+
const historyUrl = getHistoryUrl(target, branch, `${taskStatePath}/decisions/${file}`);
|
|
959
|
+
findingLines.push("> [!NOTE]", `> This finding was rejected before, in \`${file}\` ([history](${historyUrl})). It is reported again because the judged content changed: \`${fragment}\` → \`${finding.fragment}\`.`, ...claim ? [">", `> **Previous claim:** ${inline(claim)}`] : [], ">", `> **Answer:** ${inline(reason)}`, "");
|
|
960
|
+
}
|
|
961
|
+
return findingLines;
|
|
962
|
+
};
|
|
963
|
+
const renderFindings = (heading, findings) => {
|
|
964
|
+
if (!findings.length) return;
|
|
965
|
+
lines.push(`## ${heading}`, "");
|
|
966
|
+
const byUnit = /* @__PURE__ */ new Map();
|
|
967
|
+
for (const finding of findings) byUnit.set(finding.unit, [...byUnit.get(finding.unit) ?? [], finding]);
|
|
968
|
+
for (const [unit, unitFindings] of byUnit) {
|
|
969
|
+
const { path } = unitFindings[0];
|
|
970
|
+
lines.push(`### ${path ? `[\`${unit}\`](${getBlobUrl(target, branch, path)})` : `\`${unit}\``}`, "");
|
|
971
|
+
for (const finding of unitFindings) lines.push(...renderFinding(finding));
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
renderFindings("Fixed in the report pull request", fixed);
|
|
975
|
+
renderFindings("Open", unresolved);
|
|
976
|
+
return lines.join("\n");
|
|
977
|
+
}
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/utils/text.ts
|
|
980
|
+
/**
|
|
981
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
982
|
+
* For licensing, see LICENSE.md.
|
|
983
|
+
*/
|
|
984
|
+
const HASH_LENGTH = 12;
|
|
985
|
+
const CHARACTERS = {
|
|
986
|
+
"‘": "'",
|
|
987
|
+
"’": "'",
|
|
988
|
+
"‚": "'",
|
|
989
|
+
"‛": "'",
|
|
990
|
+
"′": "'",
|
|
991
|
+
"“": "\"",
|
|
992
|
+
"”": "\"",
|
|
993
|
+
"„": "\"",
|
|
994
|
+
"‟": "\"",
|
|
995
|
+
"″": "\"",
|
|
996
|
+
"‐": "-",
|
|
997
|
+
"‑": "-",
|
|
998
|
+
"‒": "-",
|
|
999
|
+
"–": "-",
|
|
1000
|
+
"—": "-",
|
|
1001
|
+
"―": "-",
|
|
1002
|
+
"−": "-",
|
|
1003
|
+
"…": "..."
|
|
1004
|
+
};
|
|
1005
|
+
const CHARACTERS_PATTERN = new RegExp(`[${Object.keys(CHARACTERS).join("")}]`, "g");
|
|
1006
|
+
/**
|
|
1007
|
+
* Normalises prose by folding HTML entities, typographic quotes and dashes, and whitespace.
|
|
1008
|
+
* Use only when these differences are irrelevant to the audit; whitespace and entities can be significant in code.
|
|
1009
|
+
*/
|
|
1010
|
+
function normalise(text) {
|
|
1011
|
+
return decodeHTMLStrict(text).replace(CHARACTERS_PATTERN, (character) => CHARACTERS[character]).replace(/\s+/g, " ").trim();
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Returns a short, stable hash of a text. It is what the baseline and the decision files store.
|
|
1015
|
+
*/
|
|
1016
|
+
function hash$1(text) {
|
|
1017
|
+
return hash("sha256", text, "hex").slice(0, HASH_LENGTH);
|
|
1018
|
+
}
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/runner/createcontext.ts
|
|
1021
|
+
/**
|
|
1022
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1023
|
+
* For licensing, see LICENSE.md.
|
|
1024
|
+
*/
|
|
1025
|
+
/**
|
|
1026
|
+
* Creates the `ctx` of the judging phase. It can read the target, but not change it.
|
|
1027
|
+
*/
|
|
1028
|
+
function createTaskContext({ root, phase, options, agent, log }) {
|
|
1029
|
+
return {
|
|
1030
|
+
phase,
|
|
1031
|
+
options,
|
|
1032
|
+
agent,
|
|
1033
|
+
log,
|
|
1034
|
+
normalise,
|
|
1035
|
+
hash: hash$1,
|
|
1036
|
+
repo: {
|
|
1037
|
+
root,
|
|
1038
|
+
async list(patterns, { ignore = [] } = {}) {
|
|
1039
|
+
const entries = glob(patterns, {
|
|
1040
|
+
cwd: root,
|
|
1041
|
+
withFileTypes: true,
|
|
1042
|
+
exclude: ["**/node_modules/**", ...ignore]
|
|
1043
|
+
});
|
|
1044
|
+
const files = [];
|
|
1045
|
+
for await (const entry of entries) if (!entry.isDirectory()) files.push(upath.relative(root, upath.join(entry.parentPath, entry.name)));
|
|
1046
|
+
return files.sort();
|
|
1047
|
+
},
|
|
1048
|
+
async read(path) {
|
|
1049
|
+
return readFile(resolveInside(root, path), "utf8");
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Creates the `ctx` of `fix()` and `verify()`. It can also change files and run commands in the target root.
|
|
1056
|
+
*/
|
|
1057
|
+
function createWritableTaskContext(options) {
|
|
1058
|
+
const context = createTaskContext(options);
|
|
1059
|
+
return {
|
|
1060
|
+
...context,
|
|
1061
|
+
repo: {
|
|
1062
|
+
...context.repo,
|
|
1063
|
+
async write(path, content) {
|
|
1064
|
+
const absolutePath = resolveInside(options.root, path);
|
|
1065
|
+
await mkdir(upath.dirname(absolutePath), { recursive: true });
|
|
1066
|
+
await writeFile(absolutePath, content);
|
|
1067
|
+
},
|
|
1068
|
+
async remove(path) {
|
|
1069
|
+
await rm(resolveInside(options.root, path), { force: true });
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
exec(command, args = [], { timeout } = {}) {
|
|
1073
|
+
return execute({
|
|
1074
|
+
command,
|
|
1075
|
+
args,
|
|
1076
|
+
cwd: options.root,
|
|
1077
|
+
timeout
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function resolveInside(root, path) {
|
|
1083
|
+
const absolutePath = upath.resolve(root, path);
|
|
1084
|
+
if (isOutside(upath.relative(root, absolutePath))) throw new Error(`The "${path}" path is outside of the target root.`);
|
|
1085
|
+
return absolutePath;
|
|
1086
|
+
}
|
|
1087
|
+
function execute({ command, args, cwd, timeout }) {
|
|
1088
|
+
return new Promise((resolve, reject) => {
|
|
1089
|
+
const child = spawn(command, args, {
|
|
1090
|
+
cwd,
|
|
1091
|
+
timeout,
|
|
1092
|
+
stdio: [
|
|
1093
|
+
"ignore",
|
|
1094
|
+
"pipe",
|
|
1095
|
+
"pipe"
|
|
1096
|
+
]
|
|
1097
|
+
});
|
|
1098
|
+
let output = "";
|
|
1099
|
+
const append = (data) => {
|
|
1100
|
+
output = (output + data).slice(-4e3);
|
|
1101
|
+
};
|
|
1102
|
+
child.stdout.setEncoding("utf8").on("data", append);
|
|
1103
|
+
child.stderr.setEncoding("utf8").on("data", append);
|
|
1104
|
+
child.on("error", reject);
|
|
1105
|
+
child.on("close", (code, signal) => {
|
|
1106
|
+
if (code === 0) {
|
|
1107
|
+
resolve({ output });
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
let reason = `exited with code ${code}`;
|
|
1111
|
+
if (timeout && child.killed) reason = `did not finish within ${timeout} ms`;
|
|
1112
|
+
else if (signal) reason = `was stopped by ${signal}`;
|
|
1113
|
+
reject(/* @__PURE__ */ new Error(`"${[command, ...args].join(" ")}" ${reason}:\n${output}`));
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/runner/changetracker.ts
|
|
1119
|
+
/**
|
|
1120
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1121
|
+
* For licensing, see LICENSE.md.
|
|
1122
|
+
*/
|
|
1123
|
+
const MAX_GIT_OUTPUT_SIZE = 268435456;
|
|
1124
|
+
const execFileAsync = promisify(execFile);
|
|
1125
|
+
/**
|
|
1126
|
+
* Tracks what `fix()` changes in a git working tree. It sees every file git sees: tracked files and untracked files
|
|
1127
|
+
* that are not ignored. The working tree may already have changes (the kept fixes of earlier units, or local changes
|
|
1128
|
+
* in a local run). Only what changes after a snapshot counts.
|
|
1129
|
+
*
|
|
1130
|
+
* A snapshot stores the working tree as a tree object, written through a copy of the index, so the real index
|
|
1131
|
+
* is never touched.
|
|
1132
|
+
*/
|
|
1133
|
+
async function createChangeTracker(root) {
|
|
1134
|
+
const top = upath.normalize((await git(root, ["rev-parse", "--show-toplevel"])).trim());
|
|
1135
|
+
const indexPath = (await git(top, [
|
|
1136
|
+
"rev-parse",
|
|
1137
|
+
"--path-format=absolute",
|
|
1138
|
+
"--git-path",
|
|
1139
|
+
"index"
|
|
1140
|
+
])).trim();
|
|
1141
|
+
const snapshotIndexPath = `${indexPath}.ckeditor5-dev-agents`;
|
|
1142
|
+
const snapshotIndex = { GIT_INDEX_FILE: snapshotIndexPath };
|
|
1143
|
+
await rm(snapshotIndexPath, { force: true });
|
|
1144
|
+
const rawContent = {
|
|
1145
|
+
args: [
|
|
1146
|
+
"-c",
|
|
1147
|
+
"core.autocrlf=false",
|
|
1148
|
+
"-c",
|
|
1149
|
+
`core.attributesFile=${snapshotIndexPath}.attributes`
|
|
1150
|
+
],
|
|
1151
|
+
env: { GIT_ATTR_SOURCE: (await git(top, ["write-tree"], snapshotIndex)).trim() }
|
|
1152
|
+
};
|
|
1153
|
+
const getHead = async () => (await git(top, ["rev-parse", "HEAD"])).trim();
|
|
1154
|
+
const writeTree = async () => {
|
|
1155
|
+
try {
|
|
1156
|
+
await readIfExists(() => cp(indexPath, snapshotIndexPath, { preserveTimestamps: true }));
|
|
1157
|
+
await git(top, [
|
|
1158
|
+
...rawContent.args,
|
|
1159
|
+
"add",
|
|
1160
|
+
"--all"
|
|
1161
|
+
], {
|
|
1162
|
+
...snapshotIndex,
|
|
1163
|
+
...rawContent.env
|
|
1164
|
+
});
|
|
1165
|
+
return (await git(top, ["write-tree"], snapshotIndex)).trim();
|
|
1166
|
+
} finally {
|
|
1167
|
+
await rm(snapshotIndexPath, { force: true });
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
return {
|
|
1171
|
+
async capture() {
|
|
1172
|
+
return {
|
|
1173
|
+
head: await getHead(),
|
|
1174
|
+
tree: await writeTree(),
|
|
1175
|
+
index: await readIfExists(() => readFile(indexPath))
|
|
1176
|
+
};
|
|
1177
|
+
},
|
|
1178
|
+
async collect(snapshot) {
|
|
1179
|
+
const tree = await writeTree();
|
|
1180
|
+
return {
|
|
1181
|
+
changed: (await git(top, [
|
|
1182
|
+
"diff-tree",
|
|
1183
|
+
"-r",
|
|
1184
|
+
"--name-only",
|
|
1185
|
+
"-z",
|
|
1186
|
+
"--no-renames",
|
|
1187
|
+
snapshot.tree,
|
|
1188
|
+
tree
|
|
1189
|
+
])).split("\0").filter(Boolean).map((path) => upath.join(top, path)).sort(),
|
|
1190
|
+
headMoved: await getHead() !== snapshot.head
|
|
1191
|
+
};
|
|
1192
|
+
},
|
|
1193
|
+
async restore(snapshot, paths) {
|
|
1194
|
+
if (await getHead() !== snapshot.head) await git(top, [
|
|
1195
|
+
"reset",
|
|
1196
|
+
"--mixed",
|
|
1197
|
+
"--quiet",
|
|
1198
|
+
snapshot.head
|
|
1199
|
+
]);
|
|
1200
|
+
if (paths.length) {
|
|
1201
|
+
const inSnapshot = (await git(top, [
|
|
1202
|
+
"--literal-pathspecs",
|
|
1203
|
+
"ls-tree",
|
|
1204
|
+
"-r",
|
|
1205
|
+
"--name-only",
|
|
1206
|
+
"-z",
|
|
1207
|
+
snapshot.tree,
|
|
1208
|
+
"--",
|
|
1209
|
+
...paths.map((path) => upath.relative(top, path))
|
|
1210
|
+
])).split("\0").filter(Boolean);
|
|
1211
|
+
for (const path of paths) await rm(path, {
|
|
1212
|
+
force: true,
|
|
1213
|
+
recursive: true
|
|
1214
|
+
});
|
|
1215
|
+
if (inSnapshot.length) await git(top, [
|
|
1216
|
+
...rawContent.args,
|
|
1217
|
+
"--literal-pathspecs",
|
|
1218
|
+
"restore",
|
|
1219
|
+
"--source",
|
|
1220
|
+
snapshot.tree,
|
|
1221
|
+
"--worktree",
|
|
1222
|
+
"--",
|
|
1223
|
+
...inSnapshot
|
|
1224
|
+
], rawContent.env);
|
|
1225
|
+
}
|
|
1226
|
+
if (snapshot.index) await writeFile(indexPath, snapshot.index);
|
|
1227
|
+
else await rm(indexPath, { force: true });
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Runs git directly, because simple-git rejects a custom environment together with the `GIT_CONFIG_*` variables that
|
|
1233
|
+
* may be set for the process.
|
|
1234
|
+
*/
|
|
1235
|
+
async function git(cwd, args, env = {}) {
|
|
1236
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
1237
|
+
cwd,
|
|
1238
|
+
env: {
|
|
1239
|
+
...process.env,
|
|
1240
|
+
...env
|
|
1241
|
+
},
|
|
1242
|
+
encoding: "utf8",
|
|
1243
|
+
maxBuffer: MAX_GIT_OUTPUT_SIZE
|
|
1244
|
+
});
|
|
1245
|
+
return stdout;
|
|
1246
|
+
}
|
|
1247
|
+
//#endregion
|
|
1248
|
+
//#region src/runner/fixunit.ts
|
|
1249
|
+
/**
|
|
1250
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1251
|
+
* For licensing, see LICENSE.md.
|
|
1252
|
+
*/
|
|
1253
|
+
/**
|
|
1254
|
+
* Fixes one unit transactionally. Every affected unit is checked, including units whose hash depends on
|
|
1255
|
+
* files outside their own path. A later fix must preserve the accepted judgments of earlier fixes.
|
|
1256
|
+
*/
|
|
1257
|
+
async function fixUnit(runner, record, actionable) {
|
|
1258
|
+
const { task, shared, tracker, fixContext, verifyContext } = runner;
|
|
1259
|
+
const { unit } = record;
|
|
1260
|
+
const snapshot = await tracker.capture();
|
|
1261
|
+
try {
|
|
1262
|
+
await step("`fix()`", () => task.fix({
|
|
1263
|
+
...fixContext,
|
|
1264
|
+
shared,
|
|
1265
|
+
unit,
|
|
1266
|
+
findings: actionable
|
|
1267
|
+
}));
|
|
1268
|
+
const initial = await step("Inspecting the fix", () => tracker.collect(snapshot));
|
|
1269
|
+
assertAllowedChanges(runner, initial);
|
|
1270
|
+
if (!initial.changed.length) return { status: "none" };
|
|
1271
|
+
await step("`verify()`", () => task.verify?.({
|
|
1272
|
+
...verifyContext,
|
|
1273
|
+
shared,
|
|
1274
|
+
unit
|
|
1275
|
+
}));
|
|
1276
|
+
const final = await step("Inspecting the fix", () => tracker.collect(snapshot));
|
|
1277
|
+
assertAllowedChanges(runner, final);
|
|
1278
|
+
const affected = await step("Judging the fixed unit", async () => {
|
|
1279
|
+
const fragments = /* @__PURE__ */ new Map();
|
|
1280
|
+
for (const candidate of runner.units.values()) {
|
|
1281
|
+
if (candidate !== record && !candidate.current) continue;
|
|
1282
|
+
const fragment = await step(`\`contentHash()\` of "${candidate.unit.key}"`, () => runner.contentHash(candidate.unit));
|
|
1283
|
+
if (candidate === record || fragment !== candidate.current?.fragment) fragments.set(candidate, fragment);
|
|
1284
|
+
}
|
|
1285
|
+
const unjudged = [...fragments.keys()].filter((candidate) => !candidate.current?.findings);
|
|
1286
|
+
if (unjudged.length) {
|
|
1287
|
+
const proposed = await tracker.capture();
|
|
1288
|
+
await tracker.restore(snapshot, final.changed);
|
|
1289
|
+
try {
|
|
1290
|
+
for (const candidate of unjudged) {
|
|
1291
|
+
const fragment = await runner.contentHash(candidate.unit);
|
|
1292
|
+
candidate.current = {
|
|
1293
|
+
fragment,
|
|
1294
|
+
findings: await runner.judge(candidate.unit, fragment)
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
} finally {
|
|
1298
|
+
await tracker.restore(proposed, final.changed);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return fragments;
|
|
1302
|
+
});
|
|
1303
|
+
const observations = /* @__PURE__ */ new Map();
|
|
1304
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1305
|
+
for (const [candidate, fragment] of affected) {
|
|
1306
|
+
const findings = await step("Judging the fixed unit", () => runner.judge(candidate.unit, fragment));
|
|
1307
|
+
const before = candidate.current.findings;
|
|
1308
|
+
const known = new Set(before.filter((finding) => !runner.isRejected(finding)).map((finding) => finding.fingerprint));
|
|
1309
|
+
const rejected = new Set(before.filter((finding) => runner.isRejected(finding)).map((finding) => finding.fingerprint));
|
|
1310
|
+
const introduced = findings.filter((finding) => {
|
|
1311
|
+
return !known.has(finding.fingerprint) && !rejected.has(finding.fingerprint) && !runner.isRejected(finding);
|
|
1312
|
+
});
|
|
1313
|
+
if (introduced.length) throw new Error(`The fix introduced new findings: ${introduced.map((finding) => finding.ruleId).join(", ")}.`);
|
|
1314
|
+
const remaining = new Set(findings.map((finding) => finding.fingerprint));
|
|
1315
|
+
resolved.set(candidate, new Set([...known].filter((fingerprint) => !remaining.has(fingerprint))));
|
|
1316
|
+
observations.set(candidate, {
|
|
1317
|
+
fragment,
|
|
1318
|
+
findings
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
if (!actionable.some((finding) => resolved.get(record).has(finding.fingerprint))) throw new Error("The fix did not resolve any finding.");
|
|
1322
|
+
for (const [candidate, observation] of observations) candidate.current = observation;
|
|
1323
|
+
return {
|
|
1324
|
+
status: "kept",
|
|
1325
|
+
files: final.changed,
|
|
1326
|
+
resolved
|
|
1327
|
+
};
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
await tracker.restore(snapshot, (await tracker.collect(snapshot)).changed);
|
|
1330
|
+
return {
|
|
1331
|
+
status: "discarded",
|
|
1332
|
+
message: getErrorMessage(error),
|
|
1333
|
+
findings: actionable.length
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Prepends the step to the message when it fails. The checks of the harness throw outside of a step, because their
|
|
1339
|
+
* messages are complete on their own.
|
|
1340
|
+
*/
|
|
1341
|
+
async function step(label, callback) {
|
|
1342
|
+
try {
|
|
1343
|
+
return await callback();
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
throw new Error(`${label} failed: ${getErrorMessage(error)}`, { cause: error });
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
function assertAllowedChanges(runner, { changed, headMoved }) {
|
|
1349
|
+
if (headMoved) throw new Error("The fix created commits. Only the harness may commit.");
|
|
1350
|
+
const outside = changed.map((file) => upath.relative(runner.root, file)).filter((file) => isOutside(file) || !runner.task.writes.some((pattern) => posix.matchesGlob(file, pattern)));
|
|
1351
|
+
if (outside.length) throw new Error(`The fix changed files outside \`writes\`: ${outside.map((file) => `\`${file}\``).join(", ")}.`);
|
|
1352
|
+
}
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/runner/validatefindings.ts
|
|
1355
|
+
/**
|
|
1356
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1357
|
+
* For licensing, see LICENSE.md.
|
|
1358
|
+
*/
|
|
1359
|
+
const KNOWN_FIELDS = [
|
|
1360
|
+
"fingerprint",
|
|
1361
|
+
"id",
|
|
1362
|
+
"task",
|
|
1363
|
+
"unit",
|
|
1364
|
+
"ruleId",
|
|
1365
|
+
"discriminator",
|
|
1366
|
+
"detail",
|
|
1367
|
+
"fragment",
|
|
1368
|
+
"path",
|
|
1369
|
+
"reportedAt",
|
|
1370
|
+
"previousDecision",
|
|
1371
|
+
"fix"
|
|
1372
|
+
];
|
|
1373
|
+
/**
|
|
1374
|
+
* Returns the identity of a finding. Two findings with the same fingerprint are the same claim,
|
|
1375
|
+
* so the harness reports it only once.
|
|
1376
|
+
*/
|
|
1377
|
+
function getFingerprint({ task, unit, ruleId, discriminator }) {
|
|
1378
|
+
return [
|
|
1379
|
+
task,
|
|
1380
|
+
unit,
|
|
1381
|
+
ruleId,
|
|
1382
|
+
discriminator
|
|
1383
|
+
].map((part) => part.replace(/[\\|]/g, "\\$&")).join("|");
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Returns the full digest of a fingerprint. Its first 12 characters are the `id` of the finding. Longer prefixes
|
|
1387
|
+
* resolve collisions between IDs.
|
|
1388
|
+
*/
|
|
1389
|
+
function getFindingId(fingerprint) {
|
|
1390
|
+
return hash("sha256", fingerprint, "hex");
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Checks what `judge()` returned and turns it into findings the harness can store.
|
|
1394
|
+
* It throws when anything is malformed, because a judge that half-worked must not advance the baseline.
|
|
1395
|
+
*/
|
|
1396
|
+
function validateFindings(findings, { task, unit, fragment }) {
|
|
1397
|
+
if (!Array.isArray(findings)) throw new Error("`judge()` must return an array of findings.");
|
|
1398
|
+
return findings.map((finding, index) => {
|
|
1399
|
+
if (!finding || typeof finding !== "object" || Array.isArray(finding)) throw new Error(`Finding #${index} must be an object.`);
|
|
1400
|
+
const fields = finding;
|
|
1401
|
+
if (typeof fields.ruleId !== "string" || !task.ruleIds.includes(fields.ruleId)) throw new Error(`Finding #${index} refers to the unknown "${String(fields.ruleId)}" rule.`);
|
|
1402
|
+
if (typeof fields.detail !== "string" || fields.detail.trim() === "") throw new Error(`Finding #${index} must have a non-empty "detail".`);
|
|
1403
|
+
if (fields.discriminator !== void 0 && typeof fields.discriminator !== "string") throw new Error(`The "discriminator" of finding #${index} must be a string.`);
|
|
1404
|
+
const extraFields = Object.fromEntries(Object.entries(fields).filter(([key]) => !KNOWN_FIELDS.includes(key)));
|
|
1405
|
+
const identity = {
|
|
1406
|
+
task: task.id,
|
|
1407
|
+
unit: unit.key,
|
|
1408
|
+
ruleId: fields.ruleId,
|
|
1409
|
+
discriminator: fields.discriminator ?? ""
|
|
1410
|
+
};
|
|
1411
|
+
const fingerprint = getFingerprint(identity);
|
|
1412
|
+
return {
|
|
1413
|
+
fingerprint,
|
|
1414
|
+
id: hash$1(fingerprint),
|
|
1415
|
+
...identity,
|
|
1416
|
+
detail: fields.detail.trim(),
|
|
1417
|
+
fragment,
|
|
1418
|
+
...unit.path ? { path: unit.path } : {},
|
|
1419
|
+
...extraFields
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
//#endregion
|
|
1424
|
+
//#region src/runner/runtask.ts
|
|
1425
|
+
/**
|
|
1426
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1427
|
+
* For licensing, see LICENSE.md.
|
|
1428
|
+
*/
|
|
1429
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
1430
|
+
/**
|
|
1431
|
+
* Judges a bounded batch, applies verified fixes, and writes the state for the caller to publish.
|
|
1432
|
+
* Failed units and discarded fixes keep their previous baseline so the next run retries them.
|
|
1433
|
+
* Existing open findings remain until a human resolves them or a new report prunes reviewed fixes.
|
|
1434
|
+
*/
|
|
1435
|
+
async function runTask(runOptions) {
|
|
1436
|
+
const { instance: { task, directory }, target, branch, today, pruneFixed, log } = runOptions;
|
|
1437
|
+
const { options, include, exclude } = task;
|
|
1438
|
+
const root = upath.normalize(await realpath(runOptions.root));
|
|
1439
|
+
const statePath = upath.join(root, STATE_DIRECTORY, task.id);
|
|
1440
|
+
const state = await readTaskState(statePath);
|
|
1441
|
+
const usage = {
|
|
1442
|
+
cost: 0,
|
|
1443
|
+
tokens: 0
|
|
1444
|
+
};
|
|
1445
|
+
const agentWarnings = /* @__PURE__ */ new Set();
|
|
1446
|
+
const createPhaseAgent = (tools) => createAgent({
|
|
1447
|
+
taskId: task.id,
|
|
1448
|
+
config: task.agent,
|
|
1449
|
+
taskDirectory: directory,
|
|
1450
|
+
cwd: root,
|
|
1451
|
+
tools,
|
|
1452
|
+
usage,
|
|
1453
|
+
log,
|
|
1454
|
+
warn: (message) => agentWarnings.add(message)
|
|
1455
|
+
});
|
|
1456
|
+
const judgeAgent = createPhaseAgent(task.agent?.judgeTools ?? READ_ONLY_TOOLS);
|
|
1457
|
+
const fixAgent = createPhaseAgent(task.agent?.fixTools ?? [
|
|
1458
|
+
...READ_ONLY_TOOLS,
|
|
1459
|
+
"edit",
|
|
1460
|
+
"write"
|
|
1461
|
+
]);
|
|
1462
|
+
const judgeContext = createTaskContext({
|
|
1463
|
+
root,
|
|
1464
|
+
phase: "judge",
|
|
1465
|
+
options,
|
|
1466
|
+
agent: judgeAgent,
|
|
1467
|
+
log
|
|
1468
|
+
});
|
|
1469
|
+
const fixContext = createWritableTaskContext({
|
|
1470
|
+
root,
|
|
1471
|
+
phase: "fix",
|
|
1472
|
+
options,
|
|
1473
|
+
agent: fixAgent,
|
|
1474
|
+
log
|
|
1475
|
+
});
|
|
1476
|
+
const verifyContext = createWritableTaskContext({
|
|
1477
|
+
root,
|
|
1478
|
+
phase: "verify",
|
|
1479
|
+
options,
|
|
1480
|
+
agent: fixAgent,
|
|
1481
|
+
log
|
|
1482
|
+
});
|
|
1483
|
+
const shared = task.prepare ? await task.prepare(judgeContext) : void 0;
|
|
1484
|
+
const units = task.scope ? await task.scope({
|
|
1485
|
+
...judgeContext,
|
|
1486
|
+
shared,
|
|
1487
|
+
include,
|
|
1488
|
+
exclude
|
|
1489
|
+
}) : (await judgeContext.repo.list(include, { ignore: exclude })).map((path) => ({
|
|
1490
|
+
key: path,
|
|
1491
|
+
path
|
|
1492
|
+
}));
|
|
1493
|
+
const { decisions } = state;
|
|
1494
|
+
const operations = {
|
|
1495
|
+
async contentHash(unit) {
|
|
1496
|
+
return task.contentHash({
|
|
1497
|
+
...judgeContext,
|
|
1498
|
+
shared,
|
|
1499
|
+
unit
|
|
1500
|
+
});
|
|
1501
|
+
},
|
|
1502
|
+
async judge(unit, fragment) {
|
|
1503
|
+
const payload = await task.payload({
|
|
1504
|
+
...judgeContext,
|
|
1505
|
+
shared,
|
|
1506
|
+
unit
|
|
1507
|
+
});
|
|
1508
|
+
const priorDecisions = getPriorDecisions(decisions, unit.key, fragment);
|
|
1509
|
+
const findings = validateFindings(await task.judge({
|
|
1510
|
+
...judgeContext,
|
|
1511
|
+
shared,
|
|
1512
|
+
unit,
|
|
1513
|
+
payload,
|
|
1514
|
+
priorDecisions
|
|
1515
|
+
}), {
|
|
1516
|
+
task,
|
|
1517
|
+
unit,
|
|
1518
|
+
fragment
|
|
1519
|
+
});
|
|
1520
|
+
return [...new Map(findings.map((finding) => [finding.fingerprint, finding])).values()];
|
|
1521
|
+
},
|
|
1522
|
+
isRejected(finding) {
|
|
1523
|
+
return findDecisions(decisions, finding).some((decision) => decision.fragment === finding.fragment);
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
const unitRuns = /* @__PURE__ */ new Map();
|
|
1527
|
+
for (const unit of units.toSorted((a, b) => compare(a.key, b.key))) {
|
|
1528
|
+
if (unitRuns.has(unit.key)) throw new Error(`\`scope()\` returned the "${unit.key}" unit more than once.`);
|
|
1529
|
+
unitRuns.set(unit.key, {
|
|
1530
|
+
unit,
|
|
1531
|
+
fixes: /* @__PURE__ */ new Map()
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
for (const record of unitRuns.values()) try {
|
|
1535
|
+
record.current = { fragment: await operations.contentHash(record.unit) };
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
record.error = `contentHash() failed: ${getErrorMessage(error)}`;
|
|
1538
|
+
}
|
|
1539
|
+
const baseline = Object.fromEntries(Object.entries(state.baseline).filter(([key]) => unitRuns.has(key)));
|
|
1540
|
+
const open = state.open.filter((finding) => !pruneFixed || !finding.fix);
|
|
1541
|
+
const openFingerprints = new Set(open.map((finding) => finding.fingerprint));
|
|
1542
|
+
const changedUnits = [...unitRuns.values()].filter(({ unit, current }) => current && current.fragment !== baseline[unit.key]);
|
|
1543
|
+
const takenUnits = changedUnits.slice(0, task.maxUnits);
|
|
1544
|
+
const deferred = changedUnits.length - takenUnits.length;
|
|
1545
|
+
log(`${units.length} units in scope, ${changedUnits.length} changed, ${takenUnits.length} taken in this run` + (deferred ? ` (${deferred} left for the next runs).` : "."));
|
|
1546
|
+
if (takenUnits.length) log(`Judging ${takenUnits.length} units...`);
|
|
1547
|
+
const queue = [...takenUnits];
|
|
1548
|
+
await Promise.all(Array.from({ length: task.concurrency ?? DEFAULT_CONCURRENCY }, async () => {
|
|
1549
|
+
for (let record = queue.shift(); record; record = queue.shift()) try {
|
|
1550
|
+
record.judged = await operations.judge(record.unit, record.current.fragment);
|
|
1551
|
+
record.current.findings = record.judged;
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
record.error = getErrorMessage(error);
|
|
1554
|
+
}
|
|
1555
|
+
}));
|
|
1556
|
+
const getActionable = (record) => {
|
|
1557
|
+
return record.judged ? record.current.findings.filter((finding) => !operations.isRejected(finding)) : [];
|
|
1558
|
+
};
|
|
1559
|
+
const toFix = takenUnits.filter((record) => getActionable(record).length).length;
|
|
1560
|
+
let files = [];
|
|
1561
|
+
if (task.fix && toFix) {
|
|
1562
|
+
const tracker = await createChangeTracker(root);
|
|
1563
|
+
const beforeFixes = await tracker.capture();
|
|
1564
|
+
const fixer = {
|
|
1565
|
+
...operations,
|
|
1566
|
+
task,
|
|
1567
|
+
shared,
|
|
1568
|
+
root,
|
|
1569
|
+
units: unitRuns,
|
|
1570
|
+
tracker,
|
|
1571
|
+
fixContext,
|
|
1572
|
+
verifyContext
|
|
1573
|
+
};
|
|
1574
|
+
log(`Fixing ${toFix} units...`);
|
|
1575
|
+
for (const record of takenUnits) {
|
|
1576
|
+
const actionable = getActionable(record);
|
|
1577
|
+
if (!actionable.length) continue;
|
|
1578
|
+
const outcome = await fixUnit(fixer, record, actionable);
|
|
1579
|
+
if (outcome.status === "kept") {
|
|
1580
|
+
const fix = {
|
|
1581
|
+
files: outcome.files.map((file) => upath.relative(root, file)),
|
|
1582
|
+
fixedAt: today
|
|
1583
|
+
};
|
|
1584
|
+
for (const [affected, fingerprints] of outcome.resolved) for (const fingerprint of fingerprints) affected.fixes.set(fingerprint, fix);
|
|
1585
|
+
} else if (outcome.status === "discarded") record.discarded = outcome;
|
|
1586
|
+
}
|
|
1587
|
+
files = (await tracker.collect(beforeFixes)).changed;
|
|
1588
|
+
}
|
|
1589
|
+
const judged = [...unitRuns.values()].filter((record) => record.judged);
|
|
1590
|
+
const counts = {
|
|
1591
|
+
newFindings: 0,
|
|
1592
|
+
alreadyOpen: 0,
|
|
1593
|
+
rejected: 0
|
|
1594
|
+
};
|
|
1595
|
+
for (const { unit, current, judged: findings, discarded } of judged) {
|
|
1596
|
+
for (const finding of findings) if (openFingerprints.has(finding.fingerprint)) counts.alreadyOpen++;
|
|
1597
|
+
else if (operations.isRejected(finding)) counts.rejected++;
|
|
1598
|
+
else {
|
|
1599
|
+
const decision = findDecisions(decisions, finding).at(-1);
|
|
1600
|
+
counts.newFindings++;
|
|
1601
|
+
open.push({
|
|
1602
|
+
...finding,
|
|
1603
|
+
reportedAt: today,
|
|
1604
|
+
previousDecision: decision && {
|
|
1605
|
+
file: decision.file,
|
|
1606
|
+
fragment: decision.fragment,
|
|
1607
|
+
finding: decision.finding,
|
|
1608
|
+
reason: decision.reason
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
if (!discarded) baseline[unit.key] = current.fragment;
|
|
1613
|
+
}
|
|
1614
|
+
const fixed = open.filter((finding) => unitRuns.get(finding.unit)?.fixes.has(finding.fingerprint));
|
|
1615
|
+
for (const finding of fixed) {
|
|
1616
|
+
const record = unitRuns.get(finding.unit);
|
|
1617
|
+
finding.fix = {
|
|
1618
|
+
...record.fixes.get(finding.fingerprint),
|
|
1619
|
+
fragment: record.current.fragment
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
await writeTaskState(statePath, {
|
|
1623
|
+
baseline,
|
|
1624
|
+
open,
|
|
1625
|
+
report: renderReport({
|
|
1626
|
+
task,
|
|
1627
|
+
target,
|
|
1628
|
+
branch,
|
|
1629
|
+
open
|
|
1630
|
+
})
|
|
1631
|
+
});
|
|
1632
|
+
const errors = [...unitRuns.values()].filter((record) => record.error !== void 0).map(({ unit, error }) => ({
|
|
1633
|
+
unit: unit.key,
|
|
1634
|
+
message: error
|
|
1635
|
+
}));
|
|
1636
|
+
const discardedRuns = judged.filter((record) => record.discarded);
|
|
1637
|
+
return {
|
|
1638
|
+
summary: {
|
|
1639
|
+
task: task.id,
|
|
1640
|
+
title: task.title,
|
|
1641
|
+
units: units.length,
|
|
1642
|
+
changed: changedUnits.length,
|
|
1643
|
+
judged: judged.length,
|
|
1644
|
+
deferred,
|
|
1645
|
+
...counts,
|
|
1646
|
+
fixed: fixed.length,
|
|
1647
|
+
discarded: discardedRuns.reduce((total, record) => total + record.discarded.findings, 0),
|
|
1648
|
+
changedFiles: files.length,
|
|
1649
|
+
open: open.filter((finding) => !finding.fix).length,
|
|
1650
|
+
errors: errors.length,
|
|
1651
|
+
problems: [
|
|
1652
|
+
...errors,
|
|
1653
|
+
...discardedRuns.map(({ unit, discarded }) => ({
|
|
1654
|
+
unit: unit.key,
|
|
1655
|
+
message: `The fix was discarded. ${discarded.message}`
|
|
1656
|
+
})),
|
|
1657
|
+
...[...state.warnings, ...agentWarnings].map((message) => ({ message }))
|
|
1658
|
+
],
|
|
1659
|
+
cost: usage.cost,
|
|
1660
|
+
tokens: usage.tokens
|
|
1661
|
+
},
|
|
1662
|
+
files,
|
|
1663
|
+
open
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/runner/runtarget.ts
|
|
1668
|
+
/**
|
|
1669
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1670
|
+
* For licensing, see LICENSE.md.
|
|
1671
|
+
*/
|
|
1672
|
+
/**
|
|
1673
|
+
* Runs the selected tasks against one target, in the working tree of the checkout.
|
|
1674
|
+
*
|
|
1675
|
+
* Both flows judge, fix, and write the state in the checkout. Kept fixes and the state stay in the files. A local run
|
|
1676
|
+
* stops there: nothing is committed or pushed. A published run does every task on its own report branch: it continues
|
|
1677
|
+
* the open report pull request or starts a new branch at the base branch, commits and pushes once, opens or updates
|
|
1678
|
+
* the pull request, and checks out the base branch again.
|
|
1679
|
+
*/
|
|
1680
|
+
async function runTarget(options) {
|
|
1681
|
+
const { target, workspace, github, log } = options;
|
|
1682
|
+
const instances = [...target.tasks.values()].filter(({ task, enabled }) => {
|
|
1683
|
+
return options.taskIds.length ? options.taskIds.includes(task.id) : enabled;
|
|
1684
|
+
});
|
|
1685
|
+
if (!instances.length) {
|
|
1686
|
+
log(`No tasks are enabled for the "${target.name}" target. Skipping.`);
|
|
1687
|
+
return [];
|
|
1688
|
+
}
|
|
1689
|
+
const root = upath.join(workspace.path, target.root);
|
|
1690
|
+
if (!existsSync(root)) throw new Error(`The root of the "${target.name}" target, "${target.root}", does not exist in the checkout.`);
|
|
1691
|
+
const results = [];
|
|
1692
|
+
for (const instance of instances) {
|
|
1693
|
+
const { task } = instance;
|
|
1694
|
+
const startedAt = Date.now();
|
|
1695
|
+
let result;
|
|
1696
|
+
log(`Running the "${task.id}" task...`);
|
|
1697
|
+
try {
|
|
1698
|
+
result = github ? await publishTask(options, github, root, instance) : { summary: (await executeTask(options, root, instance, false)).summary };
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
result = { failure: getErrorMessage(error) };
|
|
1701
|
+
}
|
|
1702
|
+
if (result.failure) log(` The "${task.id}" task failed: ${result.failure}`);
|
|
1703
|
+
results.push({
|
|
1704
|
+
task: task.id,
|
|
1705
|
+
title: task.title,
|
|
1706
|
+
durationMs: Date.now() - startedAt,
|
|
1707
|
+
...result
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
return results;
|
|
1711
|
+
}
|
|
1712
|
+
function getReportBranch(taskId, target, branch) {
|
|
1713
|
+
return `${REPORT_BRANCH_PREFIX}/${taskId}/${target.name}/${branch}`;
|
|
1714
|
+
}
|
|
1715
|
+
function executeTask(options, root, instance, pruneFixed) {
|
|
1716
|
+
const { target, workspace, today, log } = options;
|
|
1717
|
+
return runTask({
|
|
1718
|
+
instance,
|
|
1719
|
+
target,
|
|
1720
|
+
root,
|
|
1721
|
+
branch: workspace.branch,
|
|
1722
|
+
today,
|
|
1723
|
+
pruneFixed,
|
|
1724
|
+
log: (message) => log(` ${message}`)
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
async function publishTask(options, github, root, instance) {
|
|
1728
|
+
const { target, workspace, today, buildUrl, log } = options;
|
|
1729
|
+
const { task } = instance;
|
|
1730
|
+
const { branch } = workspace;
|
|
1731
|
+
const reportBranch = getReportBranch(task.id, target, branch);
|
|
1732
|
+
let pullRequest = await github.findOpenPullRequest({
|
|
1733
|
+
slug: target.slug,
|
|
1734
|
+
head: reportBranch
|
|
1735
|
+
});
|
|
1736
|
+
let saved;
|
|
1737
|
+
let failure;
|
|
1738
|
+
try {
|
|
1739
|
+
await workspace.checkoutReportBranch({
|
|
1740
|
+
name: reportBranch,
|
|
1741
|
+
continueExisting: Boolean(pullRequest)
|
|
1742
|
+
});
|
|
1743
|
+
log(pullRequest ? ` Continuing the open report pull request: ${pullRequest.html_url}` : ` Starting the "${reportBranch}" report branch at "${branch}".`);
|
|
1744
|
+
saved = await executeTask(options, root, instance, !pullRequest);
|
|
1745
|
+
const paths = [upath.join(target.root, STATE_DIRECTORY, task.id), ...saved.files.map((file) => upath.relative(workspace.path, file))];
|
|
1746
|
+
const judged = saved.summary.judged;
|
|
1747
|
+
if (await workspace.commit(paths, `${task.title}: ${judged} ${plural(judged, "unit")} judged.`)) {
|
|
1748
|
+
await workspace.push();
|
|
1749
|
+
const fixed = saved.open.filter((finding) => finding.fix).length;
|
|
1750
|
+
const body = renderPullRequestBody({
|
|
1751
|
+
task,
|
|
1752
|
+
target,
|
|
1753
|
+
branch,
|
|
1754
|
+
reportBranch,
|
|
1755
|
+
open: saved.open.length - fixed,
|
|
1756
|
+
fixed
|
|
1757
|
+
});
|
|
1758
|
+
if (pullRequest) await github.updatePullRequest({
|
|
1759
|
+
slug: target.slug,
|
|
1760
|
+
number: pullRequest.number,
|
|
1761
|
+
body
|
|
1762
|
+
});
|
|
1763
|
+
else {
|
|
1764
|
+
pullRequest = await github.createPullRequest({
|
|
1765
|
+
slug: target.slug,
|
|
1766
|
+
head: reportBranch,
|
|
1767
|
+
base: branch,
|
|
1768
|
+
title: `${task.title}: ${target.name} (${branch})`,
|
|
1769
|
+
body
|
|
1770
|
+
});
|
|
1771
|
+
log(` Opened the report pull request: ${pullRequest.html_url}`);
|
|
1772
|
+
}
|
|
1773
|
+
await github.createComment({
|
|
1774
|
+
slug: target.slug,
|
|
1775
|
+
number: pullRequest.number,
|
|
1776
|
+
body: renderRunComment({
|
|
1777
|
+
today,
|
|
1778
|
+
buildUrl,
|
|
1779
|
+
summary: saved.summary
|
|
1780
|
+
})
|
|
1781
|
+
});
|
|
1782
|
+
} else log(" Nothing new. Nothing was pushed.");
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
failure = getErrorMessage(error);
|
|
1785
|
+
} finally {
|
|
1786
|
+
try {
|
|
1787
|
+
await workspace.returnToBase();
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
const message = `Returning to the base branch failed: ${getErrorMessage(error)}`;
|
|
1790
|
+
failure = failure ? `${failure}\n${message}` : message;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return saved ? {
|
|
1794
|
+
summary: saved.summary,
|
|
1795
|
+
failure,
|
|
1796
|
+
pullRequestUrl: pullRequest?.html_url
|
|
1797
|
+
} : { failure };
|
|
1798
|
+
}
|
|
1799
|
+
//#endregion
|
|
1800
|
+
//#region src/runtasks.ts
|
|
1801
|
+
/**
|
|
1802
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1803
|
+
* For licensing, see LICENSE.md.
|
|
1804
|
+
*/
|
|
1805
|
+
/**
|
|
1806
|
+
* Runs the tasks against the targets in the checkout. The result is not `ok` when a task or a unit failed. Findings
|
|
1807
|
+
* never make a run fail.
|
|
1808
|
+
*/
|
|
1809
|
+
async function runTasks(options) {
|
|
1810
|
+
const { log = console.log, tasks: taskIds, token } = options;
|
|
1811
|
+
const targets = await loadConfig(options.configPath);
|
|
1812
|
+
const workspace = await openWorkspace({
|
|
1813
|
+
cwd: options.cwd,
|
|
1814
|
+
author: options.author
|
|
1815
|
+
});
|
|
1816
|
+
const selectedTargets = selectTargets(targets, {
|
|
1817
|
+
originSlug: workspace.originSlug,
|
|
1818
|
+
names: options.targets,
|
|
1819
|
+
taskIds
|
|
1820
|
+
});
|
|
1821
|
+
const publish = Boolean(token);
|
|
1822
|
+
const github = publish ? createGitHubClient(token) : void 0;
|
|
1823
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1824
|
+
const { branch } = workspace;
|
|
1825
|
+
const results = [];
|
|
1826
|
+
if (publish) await workspace.assertPublishable(selectedTargets.flatMap((target) => [...target.tasks.keys()].map((taskId) => {
|
|
1827
|
+
return upath.join(target.root, STATE_DIRECTORY, taskId, "open.json");
|
|
1828
|
+
})));
|
|
1829
|
+
for (const target of selectedTargets) {
|
|
1830
|
+
const targetResult = {
|
|
1831
|
+
target: target.name,
|
|
1832
|
+
branch,
|
|
1833
|
+
publish
|
|
1834
|
+
};
|
|
1835
|
+
log(`\n○ Running "${target.name}" (${target.slug}, ${branch})...`);
|
|
1836
|
+
try {
|
|
1837
|
+
results.push({
|
|
1838
|
+
...targetResult,
|
|
1839
|
+
results: await runTarget({
|
|
1840
|
+
target,
|
|
1841
|
+
taskIds,
|
|
1842
|
+
workspace,
|
|
1843
|
+
github,
|
|
1844
|
+
today,
|
|
1845
|
+
buildUrl: options.buildUrl,
|
|
1846
|
+
log: (message) => log(` ${message}`)
|
|
1847
|
+
})
|
|
1848
|
+
});
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
log(` Running "${target.name}" failed: ${getErrorMessage(error)}`);
|
|
1851
|
+
results.push({
|
|
1852
|
+
...targetResult,
|
|
1853
|
+
results: [],
|
|
1854
|
+
error: getErrorMessage(error)
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
return {
|
|
1859
|
+
ok: results.every(({ results: taskResults, error }) => {
|
|
1860
|
+
return !error && taskResults.every(({ failure, summary }) => !failure && !summary?.errors);
|
|
1861
|
+
}),
|
|
1862
|
+
targets: results
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
//#endregion
|
|
1866
|
+
//#region src/report/renderconsolesummary.ts
|
|
1867
|
+
/**
|
|
1868
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1869
|
+
* For licensing, see LICENSE.md.
|
|
1870
|
+
*/
|
|
1871
|
+
/**
|
|
1872
|
+
* Renders what a run did, as plain text for the console: one block per task of every target.
|
|
1873
|
+
*/
|
|
1874
|
+
function renderConsoleSummary(targets) {
|
|
1875
|
+
return targets.flatMap((target) => {
|
|
1876
|
+
const heading = `${target.target} (${target.branch})`;
|
|
1877
|
+
if (target.error) return [[`${heading} · failed`, row("Error", inline(target.error))].join("\n")];
|
|
1878
|
+
if (!target.results.length) return [`${heading} · no enabled tasks`];
|
|
1879
|
+
return target.results.map((result) => renderTask(result, `${heading} · ${target.publish ? "published" : "local"}`));
|
|
1880
|
+
}).join("\n\n");
|
|
1881
|
+
}
|
|
1882
|
+
function renderTask(result, heading) {
|
|
1883
|
+
const lines = [`${result.title} · ${heading}`];
|
|
1884
|
+
const { summary, failure } = result;
|
|
1885
|
+
if (!summary) {
|
|
1886
|
+
lines.push(row("Failed", inline(failure)), row("Time", formatDuration(result.durationMs)));
|
|
1887
|
+
return lines.join("\n");
|
|
1888
|
+
}
|
|
1889
|
+
const problems = [...failure ? [`Publishing failed: ${inline(failure)}`] : [], ...summary.problems.map(({ unit, message }) => inline(unit ? `${unit}: ${message}` : message))];
|
|
1890
|
+
lines.push(row("Units", `${summary.units} in scope · ${summary.changed} changed · ${summary.judged} judged` + (summary.deferred ? ` · ${summary.deferred} left for the next runs` : "")), row("Findings", `${summary.newFindings} new · ${summary.alreadyOpen} already open · ${summary.rejected} rejected`), row("Fixes", `${summary.fixed} fixed · ${summary.discarded} discarded`), row("Files", `${summary.changedFiles} changed`), row("Open", String(summary.open)), row("Cost", `$${summary.cost.toFixed(2)} · ${summary.tokens} tokens · ${formatDuration(result.durationMs)}`), row("Problems", problems.length ? String(problems.length) : "none"), ...problems.map((problem) => row("", `• ${problem}`)));
|
|
1891
|
+
if (result.pullRequestUrl) lines.push(row("Pull request", result.pullRequestUrl));
|
|
1892
|
+
return lines.join("\n");
|
|
1893
|
+
}
|
|
1894
|
+
function row(label, value) {
|
|
1895
|
+
return ` ${label.padEnd(13)}${value}`;
|
|
1896
|
+
}
|
|
1897
|
+
function formatDuration(milliseconds) {
|
|
1898
|
+
const tenths = Math.round(milliseconds / 100);
|
|
1899
|
+
if (tenths < 600) return `${(tenths / 10).toFixed(1)} s`;
|
|
1900
|
+
const seconds = Math.round(milliseconds / 1e3);
|
|
1901
|
+
return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
|
|
1902
|
+
}
|
|
1903
|
+
//#endregion
|
|
1904
|
+
//#region src/commands/resolvefinding.ts
|
|
1905
|
+
/**
|
|
1906
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
1907
|
+
* For licensing, see LICENSE.md.
|
|
1908
|
+
*/
|
|
1909
|
+
/**
|
|
1910
|
+
* Rejects or dismisses one finding in the working tree of the checkout, by its short ID:
|
|
1911
|
+
*
|
|
1912
|
+
* 1. If the finding has a fix, every file of the fix is restored: from the base branch on a report branch,
|
|
1913
|
+
* from `HEAD` otherwise.
|
|
1914
|
+
* 2. If other fixes changed any of those files, they are lost too. Without `force`, nothing changes and the error
|
|
1915
|
+
* lists them. With `force`, all their files are restored as well, their findings lose the fix, and their units are
|
|
1916
|
+
* judged and fixed again by the next run.
|
|
1917
|
+
* 3. The finding leaves `open.json`. When rejecting, a decision file records the reason.
|
|
1918
|
+
* 4. `report.md` is regenerated. The changes stay in the working tree for a human to review and commit.
|
|
1919
|
+
*/
|
|
1920
|
+
async function resolveFinding(options) {
|
|
1921
|
+
const { tasks: taskIds } = options;
|
|
1922
|
+
const targets = await loadConfig(options.configPath);
|
|
1923
|
+
const workspace = await openWorkspace({ cwd: options.cwd });
|
|
1924
|
+
const selectedTargets = selectTargets(targets, {
|
|
1925
|
+
originSlug: workspace.originSlug,
|
|
1926
|
+
names: options.targets,
|
|
1927
|
+
taskIds
|
|
1928
|
+
});
|
|
1929
|
+
const entries = await readStates(workspace, [...targets.values()].filter((target) => target.slug === workspace.originSlug));
|
|
1930
|
+
const { entry, finding } = findFinding(entries.filter((entry) => selectedTargets.includes(entry.target) && (!taskIds.length || taskIds.includes(entry.task.id))), options.id);
|
|
1931
|
+
const reportBranch = parseReportBranch(workspace.branch);
|
|
1932
|
+
if (reportBranch && (reportBranch.task !== entry.task.id || reportBranch.target !== entry.target.name)) throw new Error(`The [${finding.id}] finding belongs to the "${entry.task.id}" task in the "${entry.target.name}" target, but the "${workspace.branch}" report branch is checked out. Check out "${getReportBranch(entry.task.id, entry.target, reportBranch.base)}" first.`);
|
|
1933
|
+
const affected = getAffectedFixes(entries, finding);
|
|
1934
|
+
const others = affected.filter((item) => item.finding !== finding);
|
|
1935
|
+
if (others.length && !options.force) throw new Error([
|
|
1936
|
+
`Reverting the fix of [${finding.id}] also undoes these fixes, because they changed the same files:`,
|
|
1937
|
+
...others.map(({ finding: other, files }) => {
|
|
1938
|
+
return ` [${other.id}] ${describe(other)} (${other.task}): ${files.join(", ")}`;
|
|
1939
|
+
}),
|
|
1940
|
+
"Nothing was changed. Re-run with \"--force\" to revert them too. Their findings then stay open, and the next run judges and fixes them again."
|
|
1941
|
+
].join("\n"));
|
|
1942
|
+
const baseBranch = reportBranch?.base;
|
|
1943
|
+
const fixFiles = [...new Set(affected.flatMap((item) => item.files))].sort();
|
|
1944
|
+
const canRestore = !fixFiles.length || Boolean(baseBranch) || await workspace.hasChanges(fixFiles);
|
|
1945
|
+
const revertedFiles = canRestore ? fixFiles : [];
|
|
1946
|
+
if (revertedFiles.length) await workspace.restore(revertedFiles, baseBranch ? await getRestoreRef(workspace, baseBranch) : "HEAD");
|
|
1947
|
+
for (const item of affected) {
|
|
1948
|
+
delete item.entry.state.baseline[item.finding.unit];
|
|
1949
|
+
delete item.finding.fix;
|
|
1950
|
+
}
|
|
1951
|
+
entry.state.open = entry.state.open.filter((candidate) => candidate !== finding);
|
|
1952
|
+
const decisionFile = options.action === "reject" ? await writeDecision(entry, finding, options.reason, workspace) : void 0;
|
|
1953
|
+
for (const { target, task, statePath, state: { baseline, open } } of /* @__PURE__ */ new Set([entry, ...affected.map((item) => item.entry)])) await writeTaskState(statePath, {
|
|
1954
|
+
baseline,
|
|
1955
|
+
open,
|
|
1956
|
+
report: renderReport({
|
|
1957
|
+
task,
|
|
1958
|
+
target,
|
|
1959
|
+
branch: baseBranch ?? workspace.branch,
|
|
1960
|
+
open
|
|
1961
|
+
})
|
|
1962
|
+
});
|
|
1963
|
+
return {
|
|
1964
|
+
action: options.action,
|
|
1965
|
+
target: entry.target.name,
|
|
1966
|
+
finding,
|
|
1967
|
+
decisionFile,
|
|
1968
|
+
revertedFiles,
|
|
1969
|
+
unrevertedFiles: canRestore ? [] : fixFiles,
|
|
1970
|
+
reopened: others.map((item) => item.finding)
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Renders what `resolveFinding()` did, as plain text for the console.
|
|
1975
|
+
*/
|
|
1976
|
+
function renderResolvedFinding(result) {
|
|
1977
|
+
const { finding } = result;
|
|
1978
|
+
const lines = [`${result.action === "reject" ? "Rejected" : "Dismissed"} [${finding.id}] ${describe(finding)} (${finding.task}, ${result.target}).`];
|
|
1979
|
+
if (result.decisionFile) lines.push(` Decision ${result.decisionFile}`);
|
|
1980
|
+
if (result.unrevertedFiles.length) lines.push(` Reverted nothing: ${result.unrevertedFiles.join(", ")} had no uncommitted changes. If the fix is committed, revert it by hand.`);
|
|
1981
|
+
else if (result.revertedFiles.length) lines.push(` Reverted ${result.revertedFiles.join(", ")}`);
|
|
1982
|
+
else lines.push(" Reverted nothing (the finding had no fix)");
|
|
1983
|
+
for (const item of result.reopened) lines.push(` Reopened [${item.id}] ${describe(item)} (${item.task}), fixed again on the next run`);
|
|
1984
|
+
lines.push("", "Nothing was committed. Review the changes with \"git status\" and \"git diff HEAD\", then commit them.");
|
|
1985
|
+
return lines.join("\n");
|
|
1986
|
+
}
|
|
1987
|
+
async function readStates(workspace, targets) {
|
|
1988
|
+
const entries = [];
|
|
1989
|
+
for (const target of targets) for (const { task } of target.tasks.values()) {
|
|
1990
|
+
const statePath = upath.join(workspace.path, target.root, STATE_DIRECTORY, task.id);
|
|
1991
|
+
entries.push({
|
|
1992
|
+
target,
|
|
1993
|
+
task,
|
|
1994
|
+
statePath,
|
|
1995
|
+
state: await readTaskState(statePath)
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
return entries;
|
|
1999
|
+
}
|
|
2000
|
+
function findFinding(entries, id) {
|
|
2001
|
+
const prefix = id.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
2002
|
+
const matches = entries.flatMap((entry) => {
|
|
2003
|
+
return entry.state.open.filter((finding) => getFindingId(finding.fingerprint).startsWith(prefix)).map((finding) => ({
|
|
2004
|
+
entry,
|
|
2005
|
+
finding
|
|
2006
|
+
}));
|
|
2007
|
+
});
|
|
2008
|
+
if (!prefix || !matches.length) throw new Error(`No open finding has the "${id}" ID.`);
|
|
2009
|
+
if (matches.length > 1) throw new Error([`The "${id}" ID matches more than one finding. Use more characters of the ID:`, ...matches.map(({ finding }) => ` [${getFindingId(finding.fingerprint)}] ${describe(finding)} (${finding.task})`)].join("\n"));
|
|
2010
|
+
return matches[0];
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Returns the fix of the finding, and every other fix that shares a file with the ones found so far. Restoring a shared
|
|
2014
|
+
* file undoes all of them, so each of them is undone completely.
|
|
2015
|
+
*/
|
|
2016
|
+
function getAffectedFixes(entries, finding) {
|
|
2017
|
+
const fixed = entries.flatMap((entry) => entry.state.open.filter((item) => item.fix).map((item) => ({
|
|
2018
|
+
entry,
|
|
2019
|
+
finding: item,
|
|
2020
|
+
files: item.fix.files.map((file) => upath.join(entry.target.root, file))
|
|
2021
|
+
})));
|
|
2022
|
+
const start = fixed.find((item) => item.finding === finding);
|
|
2023
|
+
if (!start) return [];
|
|
2024
|
+
const affected = [start];
|
|
2025
|
+
const files = new Set(start.files);
|
|
2026
|
+
for (let index = 0; index < affected.length; index++) for (const item of fixed) if (!affected.includes(item) && item.files.some((file) => files.has(file))) {
|
|
2027
|
+
affected.push(item);
|
|
2028
|
+
item.files.forEach((file) => files.add(file));
|
|
2029
|
+
}
|
|
2030
|
+
return affected;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Splits the name of a report branch, `ai-tasks/<task>/<target>/<base branch>`, into its parts.
|
|
2034
|
+
*/
|
|
2035
|
+
function parseReportBranch(branch) {
|
|
2036
|
+
const [prefix, task, target, ...base] = branch.split("/");
|
|
2037
|
+
return prefix === "ai-tasks" && task && target && base.length ? {
|
|
2038
|
+
task,
|
|
2039
|
+
target,
|
|
2040
|
+
base: base.join("/")
|
|
2041
|
+
} : void 0;
|
|
2042
|
+
}
|
|
2043
|
+
async function getRestoreRef(workspace, baseBranch) {
|
|
2044
|
+
for (const ref of [`origin/${baseBranch}`, baseBranch]) if (await workspace.refExists(ref)) return ref;
|
|
2045
|
+
throw new Error(`The "${baseBranch}" base branch of the "${workspace.branch}" report branch does not exist. Fetch it first.`);
|
|
2046
|
+
}
|
|
2047
|
+
async function writeDecision(entry, finding, reason, workspace) {
|
|
2048
|
+
const directory = upath.join(entry.statePath, "decisions");
|
|
2049
|
+
const name = getDecisionFileName(finding);
|
|
2050
|
+
let path = upath.join(directory, `${name}.yml`);
|
|
2051
|
+
for (let suffix = 2; existsSync(path); suffix++) path = upath.join(directory, `${name}-${suffix}.yml`);
|
|
2052
|
+
await mkdir(directory, { recursive: true });
|
|
2053
|
+
await writeFile(path, renderDecision(finding, reason));
|
|
2054
|
+
return upath.relative(workspace.path, path);
|
|
2055
|
+
}
|
|
2056
|
+
function describe(finding) {
|
|
2057
|
+
return `${finding.ruleId}${finding.discriminator ? ` (${finding.discriminator})` : ""} in ${finding.unit}`;
|
|
2058
|
+
}
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/cli.ts
|
|
2061
|
+
/**
|
|
2062
|
+
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
|
|
2063
|
+
* For licensing, see LICENSE.md.
|
|
2064
|
+
*/
|
|
2065
|
+
const COMMON_OPTIONS = {
|
|
2066
|
+
config: {
|
|
2067
|
+
type: "string",
|
|
2068
|
+
default: "ai-tasks"
|
|
2069
|
+
},
|
|
2070
|
+
cwd: {
|
|
2071
|
+
type: "string",
|
|
2072
|
+
default: "."
|
|
2073
|
+
},
|
|
2074
|
+
target: {
|
|
2075
|
+
type: "string",
|
|
2076
|
+
multiple: true,
|
|
2077
|
+
default: []
|
|
2078
|
+
},
|
|
2079
|
+
task: {
|
|
2080
|
+
type: "string",
|
|
2081
|
+
multiple: true,
|
|
2082
|
+
default: []
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
/**
|
|
2086
|
+
* Parses the arguments of the `ckeditor5-dev-agents` command:
|
|
2087
|
+
*
|
|
2088
|
+
* * `--config <path>`: the directory with `tasks/` and `targets/`. Default: `ai-tasks`.
|
|
2089
|
+
* * `--cwd <path>`: a directory inside the checkout to run in. Default: the current directory.
|
|
2090
|
+
* * `--target <name>`: the targets to run (repeatable or comma-separated). Default: the targets of the checkout.
|
|
2091
|
+
* * `--task <id>`: the tasks to run (repeatable or comma-separated). Default: the tasks each target enables.
|
|
2092
|
+
* * `--publish`: commit and push the state and the fixes, and open or update the report pull requests.
|
|
2093
|
+
*
|
|
2094
|
+
* Paths are relative to the current directory.
|
|
2095
|
+
*/
|
|
2096
|
+
function parseArguments(cliArguments, { cwd = process.cwd(), env = process.env } = {}) {
|
|
2097
|
+
const { values } = parseArgs({
|
|
2098
|
+
args: cliArguments,
|
|
2099
|
+
options: {
|
|
2100
|
+
...COMMON_OPTIONS,
|
|
2101
|
+
publish: {
|
|
2102
|
+
type: "boolean",
|
|
2103
|
+
default: false
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
});
|
|
2107
|
+
if (values.publish && !env.AI_TASKS_GITHUB_TOKEN) throw new Error("A published run needs a GitHub token in the \"AI_TASKS_GITHUB_TOKEN\" environment variable.");
|
|
2108
|
+
return {
|
|
2109
|
+
...resolveCommonArguments(values, cwd),
|
|
2110
|
+
token: values.publish ? env.AI_TASKS_GITHUB_TOKEN : void 0,
|
|
2111
|
+
author: {
|
|
2112
|
+
name: env.AI_TASKS_GIT_NAME || "CKEditor AI Tasks",
|
|
2113
|
+
email: env.AI_TASKS_GIT_EMAIL || "ai-tasks@users.noreply.github.com"
|
|
2114
|
+
},
|
|
2115
|
+
buildUrl: env.AI_TASKS_BUILD_URL
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Parses the arguments of the `reject` and `dismiss` commands:
|
|
2120
|
+
*
|
|
2121
|
+
* * `ckeditor5-dev-agents reject <id> --reason "…"`: the finding is wrong. A decision records why.
|
|
2122
|
+
* * `ckeditor5-dev-agents dismiss <id>`: the finding is dropped without a decision.
|
|
2123
|
+
*
|
|
2124
|
+
* Both accept `--force` (continue when other fixes change the same files), `--config`, `--cwd`, `--target` and `--task`.
|
|
2125
|
+
*/
|
|
2126
|
+
function parseResolveArguments(cliArguments, { cwd = process.cwd() } = {}) {
|
|
2127
|
+
const { values, positionals } = parseArgs({
|
|
2128
|
+
args: cliArguments,
|
|
2129
|
+
allowPositionals: true,
|
|
2130
|
+
options: {
|
|
2131
|
+
...COMMON_OPTIONS,
|
|
2132
|
+
reason: { type: "string" },
|
|
2133
|
+
force: {
|
|
2134
|
+
type: "boolean",
|
|
2135
|
+
default: false
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
});
|
|
2139
|
+
const [action, id, ...rest] = positionals;
|
|
2140
|
+
const reason = values.reason?.trim();
|
|
2141
|
+
if (action !== "reject" && action !== "dismiss" || !id || rest.length) throw new Error("Usage: ckeditor5-dev-agents reject <id> --reason \"…\" | ckeditor5-dev-agents dismiss <id>");
|
|
2142
|
+
if (action === "reject" && !reason) throw new Error("Rejecting a finding needs a reason. Pass it with \"--reason\".");
|
|
2143
|
+
if (action === "dismiss" && values.reason) throw new Error("The \"--reason\" argument works only with \"reject\". A dismissed finding has no decision.");
|
|
2144
|
+
return {
|
|
2145
|
+
action,
|
|
2146
|
+
id,
|
|
2147
|
+
reason,
|
|
2148
|
+
force: values.force,
|
|
2149
|
+
...resolveCommonArguments(values, cwd)
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Runs the `ckeditor5-dev-agents` command. Returns the exit code.
|
|
2154
|
+
*/
|
|
2155
|
+
async function runCli(cliArguments) {
|
|
2156
|
+
const args = cliArguments.filter((argument) => argument !== "--");
|
|
2157
|
+
try {
|
|
2158
|
+
if (args[0] === "reject" || args[0] === "dismiss") {
|
|
2159
|
+
console.log(renderResolvedFinding(await resolveFinding(parseResolveArguments(args))));
|
|
2160
|
+
return 0;
|
|
2161
|
+
}
|
|
2162
|
+
const { ok, targets } = await runTasks(parseArguments(args));
|
|
2163
|
+
console.log(`\n${renderConsoleSummary(targets)}`);
|
|
2164
|
+
if (!ok) console.error("\nSome tasks did not finish cleanly. See the problems above.");
|
|
2165
|
+
return ok ? 0 : 1;
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
console.error(getErrorMessage(error));
|
|
2168
|
+
return 1;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
function resolveCommonArguments(values, cwd) {
|
|
2172
|
+
return {
|
|
2173
|
+
configPath: upath.resolve(cwd, values.config),
|
|
2174
|
+
cwd: upath.resolve(cwd, values.cwd),
|
|
2175
|
+
targets: splitList(values.target),
|
|
2176
|
+
tasks: splitList(values.task)
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* CI passes pipeline parameters as one string, so `--target=cs,ckbox` means the same as `--target=cs --target=ckbox`.
|
|
2181
|
+
*/
|
|
2182
|
+
function splitList(values) {
|
|
2183
|
+
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
|
|
2184
|
+
}
|
|
2185
|
+
//#endregion
|
|
2186
|
+
export { parseArguments, parseResolveArguments, runCli };
|