@beignet/cli 0.0.49 → 0.0.51
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/CHANGELOG.md +32 -0
- package/README.md +54 -5
- package/dist/analysis/workspace.d.ts +1 -0
- package/dist/analysis/workspace.d.ts.map +1 -1
- package/dist/analysis/workspace.js +20 -10
- package/dist/analysis/workspace.js.map +1 -1
- package/dist/app-map-changes.d.ts +103 -0
- package/dist/app-map-changes.d.ts.map +1 -0
- package/dist/app-map-changes.js +949 -0
- package/dist/app-map-changes.js.map +1 -0
- package/dist/git-changes.d.ts +30 -0
- package/dist/git-changes.d.ts.map +1 -0
- package/dist/git-changes.js +367 -0
- package/dist/git-changes.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -4
- package/dist/index.js.map +1 -1
- package/dist/inspect.js +413 -43
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +3 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/lib.js.map +1 -1
- package/dist/make/shared.d.ts.map +1 -1
- package/dist/make/shared.js +31 -27
- package/dist/make/shared.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +0 -2
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +46 -6
- package/dist/mcp.js.map +1 -1
- package/dist/operational-process.d.ts +1 -0
- package/dist/operational-process.d.ts.map +1 -1
- package/dist/operational-process.js.map +1 -1
- package/dist/operational-runner.js +1 -0
- package/dist/operational-runner.js.map +1 -1
- package/dist/outbox.d.ts +1 -0
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +58 -12
- package/dist/outbox.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +28 -3
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +4 -1
- package/dist/templates/base.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-structure/SKILL.md +30 -5
- package/src/analysis/workspace.ts +25 -9
- package/src/app-map-changes.ts +1462 -0
- package/src/git-changes.ts +511 -0
- package/src/index.ts +84 -4
- package/src/inspect.ts +586 -51
- package/src/lib.ts +21 -0
- package/src/make/shared.ts +31 -27
- package/src/make.ts +0 -2
- package/src/mcp.ts +65 -12
- package/src/operational-process.ts +1 -0
- package/src/operational-runner.ts +1 -0
- package/src/outbox.ts +63 -12
- package/src/templates/agents.ts +28 -3
- package/src/templates/base.ts +4 -1
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnCommand } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const emptyTreeObjectIds = {
|
|
9
|
+
sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
10
|
+
sha256: "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321",
|
|
11
|
+
} as const;
|
|
12
|
+
const maxGitOutputBytes = 64 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
type GitCommandResult = Omit<
|
|
15
|
+
Awaited<ReturnType<typeof spawnCommand>>,
|
|
16
|
+
"stderr" | "stdout"
|
|
17
|
+
> & {
|
|
18
|
+
stderr: string;
|
|
19
|
+
stdout: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Git status values represented by changed-app mapping. */
|
|
23
|
+
export type GitChangedFileStatus =
|
|
24
|
+
| "added"
|
|
25
|
+
| "copied"
|
|
26
|
+
| "deleted"
|
|
27
|
+
| "modified"
|
|
28
|
+
| "renamed"
|
|
29
|
+
| "type-changed"
|
|
30
|
+
| "unmerged"
|
|
31
|
+
| "unknown"
|
|
32
|
+
| "untracked";
|
|
33
|
+
|
|
34
|
+
export type GitChangedFile = {
|
|
35
|
+
status: GitChangedFileStatus;
|
|
36
|
+
path: string;
|
|
37
|
+
oldPath?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Resolved local Git comparison used by changed-app mapping. */
|
|
41
|
+
export type GitChangeComparison =
|
|
42
|
+
| {
|
|
43
|
+
mode: "worktree";
|
|
44
|
+
comparisonCommit: string;
|
|
45
|
+
head?: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
mode: "base";
|
|
49
|
+
base: string;
|
|
50
|
+
baseCommit: string;
|
|
51
|
+
mergeBase: string;
|
|
52
|
+
head: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type GitChangeSet = {
|
|
56
|
+
repositoryRoot: string;
|
|
57
|
+
comparison: GitChangeComparison;
|
|
58
|
+
files: GitChangedFile[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export async function readGitChangeSet(options: {
|
|
62
|
+
cwd: string;
|
|
63
|
+
base?: string;
|
|
64
|
+
}): Promise<GitChangeSet> {
|
|
65
|
+
if (options.base !== undefined && options.base.length === 0) {
|
|
66
|
+
throw new Error("Git base must not be empty.");
|
|
67
|
+
}
|
|
68
|
+
const repositoryRoot = path.resolve(
|
|
69
|
+
await gitText(options.cwd, ["rev-parse", "--show-toplevel"]),
|
|
70
|
+
);
|
|
71
|
+
const head = await resolveHeadCommit(repositoryRoot);
|
|
72
|
+
|
|
73
|
+
let comparison: GitChangeComparison;
|
|
74
|
+
let comparisonCommit: string;
|
|
75
|
+
if (options.base !== undefined) {
|
|
76
|
+
if (!head) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Cannot compare --base ${JSON.stringify(options.base)} because this repository does not have a HEAD commit.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const baseCommit = await gitText(repositoryRoot, [
|
|
82
|
+
"rev-parse",
|
|
83
|
+
"--verify",
|
|
84
|
+
"--end-of-options",
|
|
85
|
+
`${options.base}^{commit}`,
|
|
86
|
+
]).catch(() => {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Could not resolve local Git base ${JSON.stringify(options.base)}. Fetch it before running Beignet; changed mapping never accesses the network.`,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
const mergeBase = await gitText(repositoryRoot, [
|
|
92
|
+
"merge-base",
|
|
93
|
+
baseCommit,
|
|
94
|
+
head,
|
|
95
|
+
]).catch(() => {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Could not find a merge base between ${JSON.stringify(options.base)} and HEAD.`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
comparisonCommit = mergeBase;
|
|
101
|
+
comparison = {
|
|
102
|
+
mode: "base",
|
|
103
|
+
base: options.base,
|
|
104
|
+
baseCommit,
|
|
105
|
+
mergeBase,
|
|
106
|
+
head,
|
|
107
|
+
};
|
|
108
|
+
} else {
|
|
109
|
+
comparisonCommit = head ?? (await emptyTreeObjectId(repositoryRoot));
|
|
110
|
+
comparison = {
|
|
111
|
+
mode: "worktree",
|
|
112
|
+
comparisonCommit,
|
|
113
|
+
...(head ? { head } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const committed =
|
|
118
|
+
comparison.mode === "base"
|
|
119
|
+
? await gitDiffOutput(repositoryRoot, [
|
|
120
|
+
comparison.mergeBase,
|
|
121
|
+
comparison.head,
|
|
122
|
+
])
|
|
123
|
+
: "";
|
|
124
|
+
const staged = await gitDiffOutput(repositoryRoot, [
|
|
125
|
+
"--cached",
|
|
126
|
+
head ?? comparisonCommit,
|
|
127
|
+
]);
|
|
128
|
+
const unstaged = await gitDiffOutput(repositoryRoot, []);
|
|
129
|
+
const untracked = await gitOutput(repositoryRoot, [
|
|
130
|
+
"ls-files",
|
|
131
|
+
"--others",
|
|
132
|
+
"--exclude-standard",
|
|
133
|
+
"-z",
|
|
134
|
+
]);
|
|
135
|
+
const files = mergeChangedFiles(
|
|
136
|
+
parseNameStatus(committed),
|
|
137
|
+
parseNameStatus(staged),
|
|
138
|
+
parseNameStatus(unstaged),
|
|
139
|
+
parseUntracked(untracked),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return { repositoryRoot, comparison, files };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function emptyTreeObjectId(repositoryRoot: string): Promise<string> {
|
|
146
|
+
const objectFormat =
|
|
147
|
+
(await tryGitText(repositoryRoot, [
|
|
148
|
+
"rev-parse",
|
|
149
|
+
"--show-object-format=storage",
|
|
150
|
+
])) ?? "sha1";
|
|
151
|
+
const objectId =
|
|
152
|
+
emptyTreeObjectIds[objectFormat as keyof typeof emptyTreeObjectIds];
|
|
153
|
+
if (!objectId) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Unsupported Git object format ${JSON.stringify(objectFormat)}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return objectId;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function hashGitChanges(
|
|
162
|
+
changeSet: Pick<GitChangeSet, "comparison" | "files" | "repositoryRoot">,
|
|
163
|
+
files = changeSet.files,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
const hash = createHash("sha256");
|
|
166
|
+
const indexEntries = await readGitIndexEntries(changeSet.repositoryRoot);
|
|
167
|
+
hash.update(JSON.stringify(changeSet.comparison));
|
|
168
|
+
hash.update("\0");
|
|
169
|
+
|
|
170
|
+
for (const file of [...files].sort(compareChangedFiles)) {
|
|
171
|
+
hash.update(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
status: file.status,
|
|
174
|
+
path: file.path,
|
|
175
|
+
oldPath: file.oldPath,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
hash.update("\0");
|
|
179
|
+
for (const relativePath of uniquePaths([file.path, file.oldPath])) {
|
|
180
|
+
hash.update(relativePath);
|
|
181
|
+
hash.update("\0index\0");
|
|
182
|
+
hash.update(indexEntries.get(relativePath) ?? "missing");
|
|
183
|
+
hash.update("\0worktree\0");
|
|
184
|
+
await hashCurrentPath(hash, changeSet.repositoryRoot, relativePath);
|
|
185
|
+
hash.update("\0");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return `sha256:${hash.digest("hex")}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readGitIndexEntries(
|
|
193
|
+
repositoryRoot: string,
|
|
194
|
+
): Promise<Map<string, string>> {
|
|
195
|
+
const output = await gitOutput(repositoryRoot, ["ls-files", "--stage", "-z"]);
|
|
196
|
+
const entries = new Map<string, string[]>();
|
|
197
|
+
for (const field of output.split("\0").filter(Boolean)) {
|
|
198
|
+
const separator = field.indexOf("\t");
|
|
199
|
+
if (separator === -1) {
|
|
200
|
+
throw new Error("Git returned an incomplete index entry.");
|
|
201
|
+
}
|
|
202
|
+
const metadata = field.slice(0, separator);
|
|
203
|
+
const file = normalizeGitPath(field.slice(separator + 1));
|
|
204
|
+
const values = entries.get(file) ?? [];
|
|
205
|
+
values.push(metadata);
|
|
206
|
+
entries.set(file, values);
|
|
207
|
+
}
|
|
208
|
+
return new Map(
|
|
209
|
+
[...entries].map(([file, values]) => [file, [...values].sort().join("\0")]),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function hashCurrentPath(
|
|
214
|
+
hash: ReturnType<typeof createHash>,
|
|
215
|
+
repositoryRoot: string,
|
|
216
|
+
relativePath: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const absolutePath = path.join(repositoryRoot, relativePath);
|
|
219
|
+
let stats: Stats;
|
|
220
|
+
try {
|
|
221
|
+
stats = await lstat(absolutePath);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
224
|
+
hash.update("missing");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (stats.isSymbolicLink()) {
|
|
231
|
+
hash.update("symlink\0");
|
|
232
|
+
hash.update(String(stats.mode));
|
|
233
|
+
hash.update("\0");
|
|
234
|
+
hash.update(await readlink(absolutePath));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (stats.isDirectory()) {
|
|
238
|
+
hash.update("directory\0");
|
|
239
|
+
const head = await tryGitText(absolutePath, [
|
|
240
|
+
"rev-parse",
|
|
241
|
+
"--verify",
|
|
242
|
+
"HEAD^{commit}",
|
|
243
|
+
]);
|
|
244
|
+
const status = await tryGitOutput(absolutePath, [
|
|
245
|
+
"status",
|
|
246
|
+
"--porcelain=v1",
|
|
247
|
+
"-z",
|
|
248
|
+
"--untracked-files=all",
|
|
249
|
+
]);
|
|
250
|
+
hash.update(head ?? "unknown-head");
|
|
251
|
+
hash.update("\0");
|
|
252
|
+
hash.update(status ?? "unknown-status");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!stats.isFile()) {
|
|
256
|
+
hash.update("special");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
hash.update("file\0");
|
|
261
|
+
hash.update(String(stats.mode));
|
|
262
|
+
hash.update("\0");
|
|
263
|
+
for await (const chunk of createReadStream(absolutePath)) {
|
|
264
|
+
hash.update(chunk as Buffer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseNameStatus(output: string): GitChangedFile[] {
|
|
269
|
+
if (!output) return [];
|
|
270
|
+
const fields = output.split("\0");
|
|
271
|
+
const files: GitChangedFile[] = [];
|
|
272
|
+
for (let index = 0; index < fields.length; ) {
|
|
273
|
+
const rawStatus = fields[index++];
|
|
274
|
+
if (!rawStatus) continue;
|
|
275
|
+
const status = changedFileStatus(rawStatus[0]);
|
|
276
|
+
if (status === "renamed" || status === "copied") {
|
|
277
|
+
const oldPath = fields[index++];
|
|
278
|
+
const nextPath = fields[index++];
|
|
279
|
+
if (!oldPath || !nextPath) {
|
|
280
|
+
throw new Error("Git returned an incomplete rename or copy record.");
|
|
281
|
+
}
|
|
282
|
+
files.push({
|
|
283
|
+
status,
|
|
284
|
+
oldPath: normalizeGitPath(oldPath),
|
|
285
|
+
path: normalizeGitPath(nextPath),
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const file = fields[index++];
|
|
290
|
+
if (!file)
|
|
291
|
+
throw new Error("Git returned an incomplete changed-file record.");
|
|
292
|
+
files.push({ status, path: normalizeGitPath(file) });
|
|
293
|
+
}
|
|
294
|
+
return files;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseUntracked(output: string): GitChangedFile[] {
|
|
298
|
+
return output
|
|
299
|
+
.split("\0")
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.map((file) => ({ status: "untracked", path: normalizeGitPath(file) }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function mergeChangedFiles(...groups: GitChangedFile[][]): GitChangedFile[] {
|
|
305
|
+
const files = new Map<string, GitChangedFile>();
|
|
306
|
+
for (const group of groups) {
|
|
307
|
+
for (const file of group) {
|
|
308
|
+
if (file.status === "renamed" && file.oldPath) {
|
|
309
|
+
const source = files.get(file.oldPath);
|
|
310
|
+
if (source) {
|
|
311
|
+
files.delete(file.oldPath);
|
|
312
|
+
const composed = composeRename(source, file);
|
|
313
|
+
files.set(
|
|
314
|
+
composed.path,
|
|
315
|
+
mergeSameDestination(files.get(composed.path), composed),
|
|
316
|
+
);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
files.set(file.path, mergeSameDestination(files.get(file.path), file));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return [...files.values()].sort(compareChangedFiles);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function composeRename(
|
|
327
|
+
source: GitChangedFile,
|
|
328
|
+
rename: GitChangedFile,
|
|
329
|
+
): GitChangedFile {
|
|
330
|
+
if (source.status === "renamed" && source.oldPath) {
|
|
331
|
+
return rename.path === source.oldPath
|
|
332
|
+
? source
|
|
333
|
+
: { status: "renamed", oldPath: source.oldPath, path: rename.path };
|
|
334
|
+
}
|
|
335
|
+
if (source.status === "copied" && source.oldPath) {
|
|
336
|
+
return { status: "copied", oldPath: source.oldPath, path: rename.path };
|
|
337
|
+
}
|
|
338
|
+
if (source.status === "added" || source.status === "untracked") {
|
|
339
|
+
return { status: source.status, path: rename.path };
|
|
340
|
+
}
|
|
341
|
+
return rename;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function mergeSameDestination(
|
|
345
|
+
previous: GitChangedFile | undefined,
|
|
346
|
+
next: GitChangedFile,
|
|
347
|
+
): GitChangedFile {
|
|
348
|
+
if (
|
|
349
|
+
previous &&
|
|
350
|
+
(previous.status === "added" ||
|
|
351
|
+
previous.status === "copied" ||
|
|
352
|
+
previous.status === "renamed" ||
|
|
353
|
+
previous.status === "untracked") &&
|
|
354
|
+
next.status !== "unmerged" &&
|
|
355
|
+
next.status !== "unknown"
|
|
356
|
+
) {
|
|
357
|
+
return previous;
|
|
358
|
+
}
|
|
359
|
+
return next;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function uniquePaths(paths: Array<string | undefined>): string[] {
|
|
363
|
+
return [...new Set(paths.filter((file): file is string => Boolean(file)))];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function changedFileStatus(status: string | undefined): GitChangedFileStatus {
|
|
367
|
+
switch (status) {
|
|
368
|
+
case "A":
|
|
369
|
+
return "added";
|
|
370
|
+
case "C":
|
|
371
|
+
return "copied";
|
|
372
|
+
case "D":
|
|
373
|
+
return "deleted";
|
|
374
|
+
case "M":
|
|
375
|
+
return "modified";
|
|
376
|
+
case "R":
|
|
377
|
+
return "renamed";
|
|
378
|
+
case "T":
|
|
379
|
+
return "type-changed";
|
|
380
|
+
case "U":
|
|
381
|
+
return "unmerged";
|
|
382
|
+
default:
|
|
383
|
+
return "unknown";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function compareChangedFiles(
|
|
388
|
+
left: GitChangedFile,
|
|
389
|
+
right: GitChangedFile,
|
|
390
|
+
): number {
|
|
391
|
+
return `${left.path}\0${left.oldPath ?? ""}\0${left.status}`.localeCompare(
|
|
392
|
+
`${right.path}\0${right.oldPath ?? ""}\0${right.status}`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeGitPath(file: string): string {
|
|
397
|
+
return process.platform === "win32" ? file.replaceAll("\\", "/") : file;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function gitText(cwd: string, args: readonly string[]): Promise<string> {
|
|
401
|
+
return (await gitOutput(cwd, args)).trim();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function resolveHeadCommit(
|
|
405
|
+
repositoryRoot: string,
|
|
406
|
+
): Promise<string | undefined> {
|
|
407
|
+
const head = await runGit(repositoryRoot, [
|
|
408
|
+
"rev-parse",
|
|
409
|
+
"--verify",
|
|
410
|
+
"HEAD^{commit}",
|
|
411
|
+
]);
|
|
412
|
+
if (head.exitCode === 0) return head.stdout.trim();
|
|
413
|
+
|
|
414
|
+
const symbolicHead = await runGit(repositoryRoot, [
|
|
415
|
+
"symbolic-ref",
|
|
416
|
+
"--quiet",
|
|
417
|
+
"HEAD",
|
|
418
|
+
]);
|
|
419
|
+
if (symbolicHead.exitCode !== 0) {
|
|
420
|
+
throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const ref = symbolicHead.stdout.trim();
|
|
424
|
+
const refExists = await runGit(repositoryRoot, [
|
|
425
|
+
"show-ref",
|
|
426
|
+
"--verify",
|
|
427
|
+
"--quiet",
|
|
428
|
+
ref,
|
|
429
|
+
]);
|
|
430
|
+
if (refExists.exitCode === 1) return undefined;
|
|
431
|
+
throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function tryGitText(
|
|
435
|
+
cwd: string,
|
|
436
|
+
args: readonly string[],
|
|
437
|
+
): Promise<string | undefined> {
|
|
438
|
+
try {
|
|
439
|
+
return await gitText(cwd, args);
|
|
440
|
+
} catch {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function tryGitOutput(
|
|
446
|
+
cwd: string,
|
|
447
|
+
args: readonly string[],
|
|
448
|
+
): Promise<string | undefined> {
|
|
449
|
+
try {
|
|
450
|
+
return await gitOutput(cwd, args);
|
|
451
|
+
} catch {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function gitOutput(
|
|
457
|
+
cwd: string,
|
|
458
|
+
args: readonly string[],
|
|
459
|
+
): Promise<string> {
|
|
460
|
+
const result = await runGit(cwd, args);
|
|
461
|
+
if (result.exitCode !== 0) throw gitCommandError(args, result);
|
|
462
|
+
return result.stdout;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function runGit(
|
|
466
|
+
cwd: string,
|
|
467
|
+
args: readonly string[],
|
|
468
|
+
): Promise<GitCommandResult> {
|
|
469
|
+
const result = await spawnCommand("git", args, cwd, {
|
|
470
|
+
captureOutput: true,
|
|
471
|
+
maxOutputBytes: maxGitOutputBytes,
|
|
472
|
+
});
|
|
473
|
+
if (result.outputTruncated) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Git output exceeded ${maxGitOutputBytes} bytes; changed mapping cannot produce a complete report.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
...result,
|
|
480
|
+
stdout: result.stdout ?? "",
|
|
481
|
+
stderr: result.stderr ?? "",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function gitCommandError(
|
|
486
|
+
args: readonly string[],
|
|
487
|
+
result: GitCommandResult,
|
|
488
|
+
): Error {
|
|
489
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
490
|
+
return new Error(
|
|
491
|
+
`Git command failed: git ${args.join(" ")}${detail ? `\n${detail}` : ""}`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function gitDiffOutput(
|
|
496
|
+
repositoryRoot: string,
|
|
497
|
+
comparisonArgs: readonly string[],
|
|
498
|
+
): Promise<string> {
|
|
499
|
+
return gitOutput(repositoryRoot, [
|
|
500
|
+
"diff",
|
|
501
|
+
"--no-ext-diff",
|
|
502
|
+
"--no-textconv",
|
|
503
|
+
"--name-status",
|
|
504
|
+
"-z",
|
|
505
|
+
"--find-renames=50%",
|
|
506
|
+
"--find-copies=50%",
|
|
507
|
+
"--find-copies-harder",
|
|
508
|
+
...comparisonArgs,
|
|
509
|
+
"--",
|
|
510
|
+
]);
|
|
511
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CommandContext,
|
|
9
9
|
type FlagParametersForType,
|
|
10
10
|
proposeCompletions,
|
|
11
|
+
type RouteMap,
|
|
11
12
|
run,
|
|
12
13
|
type StricliDynamicCommandContext,
|
|
13
14
|
type StricliProcess,
|
|
@@ -120,6 +121,8 @@ type RoutesFlags = {
|
|
|
120
121
|
type MapFlags = RoutesFlags & {
|
|
121
122
|
feature?: string;
|
|
122
123
|
kind?: readonly AppMapNodeKind[];
|
|
124
|
+
changed?: boolean;
|
|
125
|
+
base?: string;
|
|
123
126
|
};
|
|
124
127
|
|
|
125
128
|
type ExplainFlags = RoutesFlags;
|
|
@@ -178,6 +181,7 @@ type OutboxDrainFlags = {
|
|
|
178
181
|
json?: boolean;
|
|
179
182
|
module?: string;
|
|
180
183
|
batchSize?: number;
|
|
184
|
+
concurrency?: number;
|
|
181
185
|
cwd?: string;
|
|
182
186
|
};
|
|
183
187
|
|
|
@@ -391,6 +395,15 @@ const routesFlagParameters = {
|
|
|
391
395
|
|
|
392
396
|
const mapFlagParameters = {
|
|
393
397
|
json: jsonFlag,
|
|
398
|
+
changed: {
|
|
399
|
+
kind: "boolean",
|
|
400
|
+
optional: true,
|
|
401
|
+
withNegated: false,
|
|
402
|
+
brief: "Map the current Git change set to potentially affected concepts.",
|
|
403
|
+
},
|
|
404
|
+
base: parsedStringFlag(
|
|
405
|
+
"Include committed changes since the merge base of this local Git ref. Requires --changed.",
|
|
406
|
+
),
|
|
394
407
|
feature: parsedStringFlag(
|
|
395
408
|
"Project one feature and its direct relationships.",
|
|
396
409
|
),
|
|
@@ -475,7 +488,13 @@ const outboxDrainFlagParameters = {
|
|
|
475
488
|
kind: "parsed",
|
|
476
489
|
parse: parsePositiveInteger,
|
|
477
490
|
optional: true,
|
|
478
|
-
brief: "Maximum messages to
|
|
491
|
+
brief: "Maximum eligible messages to handle in one drain pass.",
|
|
492
|
+
},
|
|
493
|
+
concurrency: {
|
|
494
|
+
kind: "parsed",
|
|
495
|
+
parse: parsePositiveInteger,
|
|
496
|
+
optional: true,
|
|
497
|
+
brief: "Maximum concurrent deliveries; values above one are unordered.",
|
|
479
498
|
},
|
|
480
499
|
cwd: cwdFlag,
|
|
481
500
|
} satisfies FlagParametersForType<OutboxDrainFlags, CliContext>;
|
|
@@ -889,7 +908,7 @@ const mapCommand = buildCommand<MapFlags, [], CliContext>({
|
|
|
889
908
|
docs: {
|
|
890
909
|
brief: "Map the app's architecture and registered workflows.",
|
|
891
910
|
fullDescription:
|
|
892
|
-
"Build a deterministic graph of features, HTTP contracts, routes, use cases, policies, workflows, ports, providers, OpenAPI, tests, dependencies, and doctor findings.",
|
|
911
|
+
"Build a deterministic graph of features, HTTP contracts, routes, use cases, policies, workflows, ports, providers, OpenAPI, tests, dependencies, and doctor findings. Use --changed for a bounded, report-only semantic map of the current Git change set.",
|
|
893
912
|
},
|
|
894
913
|
parameters: {
|
|
895
914
|
flags: mapFlagParameters,
|
|
@@ -898,8 +917,35 @@ const mapCommand = buildCommand<MapFlags, [], CliContext>({
|
|
|
898
917
|
const { formatAppMap, mapApp, projectAppMap } = await import(
|
|
899
918
|
"./app-map.js"
|
|
900
919
|
);
|
|
920
|
+
const { formatAppChangeImpact, mapAppChanges } = await import(
|
|
921
|
+
"./app-map-changes.js"
|
|
922
|
+
);
|
|
901
923
|
|
|
902
924
|
return async function runMap(this: CliContext, flags: MapFlags) {
|
|
925
|
+
if (flags.base !== undefined && !flags.changed) {
|
|
926
|
+
throw new Error("map --base requires --changed.");
|
|
927
|
+
}
|
|
928
|
+
if (
|
|
929
|
+
flags.changed &&
|
|
930
|
+
(flags.feature !== undefined || flags.kind !== undefined)
|
|
931
|
+
) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
"map --changed cannot be combined with --feature or --kind.",
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if (flags.changed) {
|
|
937
|
+
const changed = await mapAppChanges({
|
|
938
|
+
cwd: flags.cwd,
|
|
939
|
+
base: flags.base,
|
|
940
|
+
});
|
|
941
|
+
writeOutput(
|
|
942
|
+
this,
|
|
943
|
+
flags.json
|
|
944
|
+
? JSON.stringify(changed, null, 2)
|
|
945
|
+
: formatAppChangeImpact(changed),
|
|
946
|
+
);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
903
949
|
const result = projectAppMap(
|
|
904
950
|
await mapApp({ cwd: flags.cwd, strict: true }),
|
|
905
951
|
{
|
|
@@ -1559,6 +1605,7 @@ const outboxDrainCommand = buildCommand<OutboxDrainFlags, [], CliContext>({
|
|
|
1559
1605
|
cwd: flags.cwd,
|
|
1560
1606
|
modulePath: flags.module,
|
|
1561
1607
|
batchSize: flags.batchSize,
|
|
1608
|
+
concurrency: flags.concurrency,
|
|
1562
1609
|
});
|
|
1563
1610
|
|
|
1564
1611
|
writeOutput(
|
|
@@ -1567,6 +1614,9 @@ const outboxDrainCommand = buildCommand<OutboxDrainFlags, [], CliContext>({
|
|
|
1567
1614
|
? JSON.stringify(result, null, 2)
|
|
1568
1615
|
: outboxDrainNextSteps(result),
|
|
1569
1616
|
);
|
|
1617
|
+
if (result.result.settlementFailed > 0 || result.result.leaseLost > 0) {
|
|
1618
|
+
this.process.exitCode = 1;
|
|
1619
|
+
}
|
|
1570
1620
|
};
|
|
1571
1621
|
},
|
|
1572
1622
|
});
|
|
@@ -2410,6 +2460,27 @@ Run npm create beignet@latest (or bun create beignet) to scaffold a new app.`,
|
|
|
2410
2460
|
},
|
|
2411
2461
|
});
|
|
2412
2462
|
|
|
2463
|
+
const completionProposalCommandPath = ["completion", "propose"] as const;
|
|
2464
|
+
|
|
2465
|
+
function commandPathsFromRouteMap(
|
|
2466
|
+
routeMap: RouteMap<CliContext>,
|
|
2467
|
+
prefix: readonly string[] = [],
|
|
2468
|
+
): string[][] {
|
|
2469
|
+
return routeMap.getAllEntries().flatMap((entry) => {
|
|
2470
|
+
const path = [...prefix, entry.name["convert-camel-to-kebab"]];
|
|
2471
|
+
if ("getAllEntries" in entry.target) {
|
|
2472
|
+
return commandPathsFromRouteMap(entry.target, path);
|
|
2473
|
+
}
|
|
2474
|
+
return [path];
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** Every canonical CLI command path, derived from runtime routing definitions. */
|
|
2479
|
+
export const cliCommandPaths: readonly (readonly string[])[] = [
|
|
2480
|
+
...commandPathsFromRouteMap(rootRoutes),
|
|
2481
|
+
completionProposalCommandPath,
|
|
2482
|
+
].sort((left, right) => left.join(" ").localeCompare(right.join(" ")));
|
|
2483
|
+
|
|
2413
2484
|
const cli = buildApplication(rootRoutes, {
|
|
2414
2485
|
name: "beignet",
|
|
2415
2486
|
versionInfo: {
|
|
@@ -2503,6 +2574,9 @@ type OutboxDrainNextStepsResult = {
|
|
|
2503
2574
|
delivered: number;
|
|
2504
2575
|
retried: number;
|
|
2505
2576
|
deadLettered: number;
|
|
2577
|
+
abandonedDeadLettered: number;
|
|
2578
|
+
settlementFailed: number;
|
|
2579
|
+
leaseLost: number;
|
|
2506
2580
|
};
|
|
2507
2581
|
};
|
|
2508
2582
|
|
|
@@ -2570,7 +2644,10 @@ Result:
|
|
|
2570
2644
|
claimed: ${result.result.claimed}
|
|
2571
2645
|
delivered: ${result.result.delivered}
|
|
2572
2646
|
retried: ${result.result.retried}
|
|
2573
|
-
deadLettered: ${result.result.deadLettered}
|
|
2647
|
+
deadLettered: ${result.result.deadLettered}
|
|
2648
|
+
abandonedDeadLettered: ${result.result.abandonedDeadLettered}
|
|
2649
|
+
settlementFailed: ${result.result.settlementFailed}
|
|
2650
|
+
leaseLost: ${result.result.leaseLost}`;
|
|
2574
2651
|
}
|
|
2575
2652
|
|
|
2576
2653
|
function formatOutboxDate(value: Date | string | null | undefined): string {
|
|
@@ -3015,7 +3092,10 @@ export async function main(
|
|
|
3015
3092
|
// Shell completion scripts call `beignet completion propose <words...>`.
|
|
3016
3093
|
// Handle it before stricli parses the inputs: the trailing words are a
|
|
3017
3094
|
// partial command line, not flags for this CLI.
|
|
3018
|
-
if (
|
|
3095
|
+
if (
|
|
3096
|
+
inputs[0] === completionProposalCommandPath[0] &&
|
|
3097
|
+
inputs[1] === completionProposalCommandPath[1]
|
|
3098
|
+
) {
|
|
3019
3099
|
await writeCompletionProposals(inputs.slice(2), context);
|
|
3020
3100
|
return;
|
|
3021
3101
|
}
|