@shiyuphan/follow-me 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
package/dist/src/session.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { parseDraft, parseReview } from "./comments.js";
|
|
5
|
+
import { isSha } from "./diff.js";
|
|
6
|
+
import { branchExists, currentBranch, defaultBaseRef, GitError, loadDiff, objectsDir, QUARANTINE_PREFIX, repoRoot, resolveCommit, } from "./git.js";
|
|
7
|
+
import { isFields, isPlanVersion, parsePlanFile, planHash, serializePlanFile, } from "./plan.js";
|
|
7
8
|
export class BranchMovedError extends Error {
|
|
8
9
|
name = "BranchMovedError";
|
|
9
10
|
}
|
|
@@ -11,6 +12,21 @@ export class BranchMovedError extends Error {
|
|
|
11
12
|
export class SessionFileError extends Error {
|
|
12
13
|
name = "SessionFileError";
|
|
13
14
|
}
|
|
15
|
+
/** draft.json has not been written yet. */
|
|
16
|
+
export class MissingDraftError extends SessionFileError {
|
|
17
|
+
path;
|
|
18
|
+
constructor(path) {
|
|
19
|
+
super(`${path} does not exist yet`);
|
|
20
|
+
this.path = path;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The command ran outside any git work tree. */
|
|
24
|
+
export class NotARepositoryError extends Error {
|
|
25
|
+
name = "NotARepositoryError";
|
|
26
|
+
constructor() {
|
|
27
|
+
super("not a git repository (run it inside the repo you want to split)");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
14
30
|
/** Another live follow-me process holds the session's lock. */
|
|
15
31
|
export class SessionLockedError extends Error {
|
|
16
32
|
name = "SessionLockedError";
|
|
@@ -118,12 +134,15 @@ function adoptLock(dir, from, command) {
|
|
|
118
134
|
export function slugOf(branch) {
|
|
119
135
|
return branch.replaceAll("/", "-");
|
|
120
136
|
}
|
|
121
|
-
function
|
|
137
|
+
function writeText(path, text) {
|
|
122
138
|
mkdirSync(dirname(path), { recursive: true });
|
|
123
139
|
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
124
|
-
writeFileSync(temp,
|
|
140
|
+
writeFileSync(temp, text);
|
|
125
141
|
renameSync(temp, path);
|
|
126
142
|
}
|
|
143
|
+
function writeJson(path, value) {
|
|
144
|
+
writeText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
145
|
+
}
|
|
127
146
|
function readJson(path) {
|
|
128
147
|
let text;
|
|
129
148
|
try {
|
|
@@ -169,101 +188,24 @@ function parseAnchor(v) {
|
|
|
169
188
|
function parseApplied(v) {
|
|
170
189
|
if (!isFields(v))
|
|
171
190
|
return null;
|
|
172
|
-
const { schema, version, branch, head } = v;
|
|
191
|
+
const { schema, version, branch, head, hash = null, signed = null } = v;
|
|
173
192
|
if (schema !== "follow-me/applied@1" || !isPlanVersion(version))
|
|
174
193
|
return null;
|
|
175
194
|
if (typeof branch !== "string" || typeof head !== "string" || !isSha(head))
|
|
176
195
|
return null;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
function parseCommentAnchor(v) {
|
|
183
|
-
if (!isFields(v))
|
|
184
|
-
return "anchor is not an object";
|
|
185
|
-
if (v.kind === "plan")
|
|
186
|
-
return { kind: "plan" };
|
|
187
|
-
if (!isCommitIndex(v.commit))
|
|
188
|
-
return "anchor.commit is not a commit index";
|
|
189
|
-
const commit = v.commit;
|
|
190
|
-
if (v.kind === "message" || v.kind === "position")
|
|
191
|
-
return { kind: v.kind, commit };
|
|
192
|
-
if (v.kind !== "hunk")
|
|
193
|
-
return "anchor.kind is unknown";
|
|
194
|
-
if (typeof v.unit !== "string" || !isUnitId(v.unit))
|
|
195
|
-
return "anchor.unit is not a unit id";
|
|
196
|
-
if (v.changes === null)
|
|
197
|
-
return { kind: "hunk", unit: v.unit, commit, changes: null };
|
|
198
|
-
if (!isArray(v.changes) || !v.changes.every(isCommitIndex)) {
|
|
199
|
-
return "anchor.changes is not null or a list of change indices";
|
|
200
|
-
}
|
|
201
|
-
return { kind: "hunk", unit: v.unit, commit, changes: v.changes.filter(isCommitIndex) };
|
|
202
|
-
}
|
|
203
|
-
function parseComment(v) {
|
|
204
|
-
if (!isFields(v))
|
|
205
|
-
return "not an object";
|
|
206
|
-
if (typeof v.id !== "string" || !isCommentId(v.id))
|
|
207
|
-
return "id is not a comment id";
|
|
208
|
-
const anchor = parseCommentAnchor(v.anchor);
|
|
209
|
-
if (typeof anchor === "string")
|
|
210
|
-
return anchor;
|
|
211
|
-
const ctx = v.context;
|
|
212
|
-
if (!isFields(ctx) || typeof ctx.commitMessage !== "string") {
|
|
213
|
-
return "context.commitMessage is not a string";
|
|
214
|
-
}
|
|
215
|
-
const path = ctx.path;
|
|
216
|
-
if (path !== null && (typeof path !== "string" || !isRepoPath(path))) {
|
|
217
|
-
return "context.path is not null or a path";
|
|
218
|
-
}
|
|
219
|
-
if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
|
|
220
|
-
return "body and createdAt must be strings";
|
|
221
|
-
}
|
|
222
|
-
return {
|
|
223
|
-
id: v.id,
|
|
224
|
-
anchor,
|
|
225
|
-
context: { commitMessage: ctx.commitMessage, path },
|
|
226
|
-
body: v.body,
|
|
227
|
-
createdAt: v.createdAt,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
/** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
|
|
231
|
-
export function parseReview(v) {
|
|
232
|
-
if (!isFields(v)) {
|
|
233
|
-
return "not an object";
|
|
234
|
-
}
|
|
235
|
-
if (v.schema !== "follow-me/review@1")
|
|
236
|
-
return 'schema is not "follow-me/review@1"';
|
|
237
|
-
const mode = v.mode ?? "split";
|
|
238
|
-
if (mode !== "split" && mode !== "walk")
|
|
239
|
-
return 'mode is not "split" or "walk"';
|
|
240
|
-
if (!isPlanVersion(v.planVersion))
|
|
241
|
-
return "planVersion is not a positive integer";
|
|
242
|
-
if (typeof v.planHash !== "string" || typeof v.endedAt !== "string") {
|
|
243
|
-
return "planHash and endedAt must be strings";
|
|
244
|
-
}
|
|
245
|
-
if (!isArray(v.comments))
|
|
246
|
-
return "comments is not an array";
|
|
247
|
-
const comments = [];
|
|
248
|
-
for (const [i, raw] of v.comments.entries()) {
|
|
249
|
-
const c = parseComment(raw);
|
|
250
|
-
if (typeof c === "string")
|
|
251
|
-
return `comments[${i}]: ${c}`;
|
|
252
|
-
comments.push(c);
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
schema: v.schema,
|
|
256
|
-
mode,
|
|
257
|
-
planVersion: v.planVersion,
|
|
258
|
-
planHash: v.planHash,
|
|
259
|
-
endedAt: v.endedAt,
|
|
260
|
-
comments,
|
|
261
|
-
};
|
|
196
|
+
if (hash !== null && typeof hash !== "string")
|
|
197
|
+
return null;
|
|
198
|
+
if (signed !== null && typeof signed !== "boolean")
|
|
199
|
+
return null;
|
|
200
|
+
return { schema, version, branch, head, hash, signed };
|
|
262
201
|
}
|
|
263
202
|
function makeSession(root, dir, anchor, diff) {
|
|
264
203
|
const plansDir = join(dir, "plans");
|
|
265
|
-
const
|
|
204
|
+
const splitReviews = join(dir, "reviews");
|
|
205
|
+
const walkReviewsDir = join(splitReviews, "walk");
|
|
206
|
+
const reviewsDir = (mode) => mode === "walk" ? walkReviewsDir : splitReviews;
|
|
266
207
|
const draftPath = join(dir, "draft.json");
|
|
208
|
+
const promptPath = join(dir, "prompt.md");
|
|
267
209
|
const appliedPath = join(dir, "applied.json");
|
|
268
210
|
const plans = () => versions(plansDir).map(({ version, path }) => {
|
|
269
211
|
const file = parsePlanFile(readJson(path));
|
|
@@ -274,30 +216,74 @@ function makeSession(root, dir, anchor, diff) {
|
|
|
274
216
|
}
|
|
275
217
|
return { version, file, hash: planHash(file) };
|
|
276
218
|
});
|
|
277
|
-
const
|
|
219
|
+
const reviewsIn = (mode) => versions(reviewsDir(mode)).map(({ version, path }) => {
|
|
278
220
|
const review = parseReview(readJson(path));
|
|
279
221
|
if (typeof review === "string")
|
|
280
222
|
throw new SessionFileError(`${path}: ${review}`);
|
|
281
223
|
return { version, review };
|
|
282
224
|
});
|
|
225
|
+
const reviews = () => reviewsIn("split");
|
|
226
|
+
const walkReviews = () => [...reviews().filter((r) => r.review.mode === "walk"), ...reviewsIn("walk")].sort((a, b) => a.version - b.version);
|
|
227
|
+
const draftVersions = (mode) => {
|
|
228
|
+
const dir = reviewsDir(mode);
|
|
229
|
+
if (!existsSync(dir))
|
|
230
|
+
return [];
|
|
231
|
+
return readdirSync(dir).flatMap((name) => {
|
|
232
|
+
const n = Number(/^v([1-9][0-9]*)\.draft\.json$/.exec(name)?.[1]);
|
|
233
|
+
return isPlanVersion(n) ? [n] : [];
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
const draftReview = (mode, version) => {
|
|
237
|
+
const path = join(reviewsDir(mode), `v${version}.draft.json`);
|
|
238
|
+
const draft = parseDraft(readJson(path));
|
|
239
|
+
if (typeof draft === "string")
|
|
240
|
+
throw new SessionFileError(`${path}: ${draft}`);
|
|
241
|
+
const { comments, concerns, resolved } = draft;
|
|
242
|
+
return {
|
|
243
|
+
schema: "follow-me/review@1",
|
|
244
|
+
mode,
|
|
245
|
+
planVersion: version,
|
|
246
|
+
planHash: plans().find((p) => p.version === version)?.hash ?? "",
|
|
247
|
+
endedAt: statSync(path).mtime.toISOString(),
|
|
248
|
+
comments,
|
|
249
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
250
|
+
...(resolved.length === 0 ? {} : { resolved }),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
const priorReview = (mode, before) => {
|
|
254
|
+
const below = (v) => before === null || v < before;
|
|
255
|
+
const done = (mode === "walk" ? walkReviews() : reviews().filter((r) => r.review.mode === mode))
|
|
256
|
+
.filter((r) => below(r.version))
|
|
257
|
+
.at(-1);
|
|
258
|
+
const unfinished = draftVersions(mode)
|
|
259
|
+
.filter((v) => below(v) && v > (done?.version ?? 0))
|
|
260
|
+
.sort((a, b) => a - b)
|
|
261
|
+
.at(-1);
|
|
262
|
+
return unfinished === undefined ? (done?.review ?? null) : draftReview(mode, unfinished);
|
|
263
|
+
};
|
|
283
264
|
return {
|
|
284
265
|
root,
|
|
285
266
|
dir,
|
|
286
267
|
anchor,
|
|
287
268
|
diff,
|
|
288
269
|
draftPath,
|
|
270
|
+
promptPath,
|
|
289
271
|
plans,
|
|
272
|
+
reviewsDir,
|
|
290
273
|
reviews,
|
|
291
|
-
|
|
274
|
+
walkReviews,
|
|
275
|
+
lastReview: () => priorReview("split", null),
|
|
276
|
+
priorReview,
|
|
292
277
|
readDraft: () => {
|
|
293
278
|
const draft = readJson(draftPath);
|
|
294
279
|
if (draft === undefined)
|
|
295
|
-
throw new
|
|
280
|
+
throw new MissingDraftError(draftPath);
|
|
296
281
|
return draft;
|
|
297
282
|
},
|
|
298
283
|
writeDraft: (draft) => writeJson(draftPath, draft),
|
|
284
|
+
writePrompt: (text) => writeText(promptPath, text),
|
|
299
285
|
writeReview: (review) => {
|
|
300
|
-
const path = join(reviewsDir, `v${review.planVersion}.json`);
|
|
286
|
+
const path = join(reviewsDir(review.mode), `v${review.planVersion}.json`);
|
|
301
287
|
if (existsSync(path))
|
|
302
288
|
throw new SessionFileError(`${path} is already written`);
|
|
303
289
|
writeJson(path, review);
|
|
@@ -324,16 +310,30 @@ function makeSession(root, dir, anchor, diff) {
|
|
|
324
310
|
throw new SessionFileError(`${appliedPath} is not an applied record`);
|
|
325
311
|
return applied;
|
|
326
312
|
},
|
|
327
|
-
recordApplied: (
|
|
313
|
+
recordApplied: (plan, result) => writeJson(appliedPath, {
|
|
328
314
|
schema: "follow-me/applied@1",
|
|
329
|
-
version,
|
|
315
|
+
version: plan.version,
|
|
330
316
|
branch: result.branch,
|
|
331
317
|
head: result.head,
|
|
318
|
+
hash: plan.hash,
|
|
319
|
+
signed: result.signed,
|
|
332
320
|
}),
|
|
333
321
|
};
|
|
334
322
|
}
|
|
323
|
+
/** The top level of the work tree holding cwd. */
|
|
324
|
+
async function rootOf(cwd) {
|
|
325
|
+
try {
|
|
326
|
+
return await repoRoot(cwd);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error instanceof GitError && error.message.includes("not a git repository")) {
|
|
330
|
+
throw new NotARepositoryError();
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
335
|
async function locate(repo, branch) {
|
|
336
|
-
const root = await
|
|
336
|
+
const root = await rootOf(repo);
|
|
337
337
|
const name = branch ?? (await currentBranch(root));
|
|
338
338
|
if (name === null)
|
|
339
339
|
throw new Error("HEAD is detached; pass --branch <name>");
|
|
@@ -421,8 +421,7 @@ function abandonedTemps(dir) {
|
|
|
421
421
|
.map((name) => join(dir, name));
|
|
422
422
|
}
|
|
423
423
|
/** Review drafts for a version at or below the latest finished review; none can be resumed. */
|
|
424
|
-
function deadReviewDrafts(
|
|
425
|
-
const reviewsDir = join(dir, "reviews");
|
|
424
|
+
function deadReviewDrafts(reviewsDir) {
|
|
426
425
|
const latest = versions(reviewsDir).at(-1)?.version ?? 0;
|
|
427
426
|
if (!existsSync(reviewsDir))
|
|
428
427
|
return [];
|
|
@@ -441,7 +440,7 @@ function deadReviewDrafts(dir) {
|
|
|
441
440
|
* a live process are left alone, and all refuses while one exists.
|
|
442
441
|
*/
|
|
443
442
|
export async function cleanSessions(repo, opts) {
|
|
444
|
-
const root = await
|
|
443
|
+
const root = await rootOf(repo);
|
|
445
444
|
const top = join(root, ".follow-me");
|
|
446
445
|
const sessions = existsSync(top)
|
|
447
446
|
? readdirSync(top, { withFileTypes: true })
|
|
@@ -466,7 +465,8 @@ export async function cleanSessions(repo, opts) {
|
|
|
466
465
|
}
|
|
467
466
|
if (lock.kind === "stale")
|
|
468
467
|
found.push(join(dir, "lock"));
|
|
469
|
-
|
|
468
|
+
const reviews = join(dir, "reviews");
|
|
469
|
+
found.push(...abandonedTemps(dir), ...deadReviewDrafts(reviews), ...deadReviewDrafts(join(reviews, "walk")));
|
|
470
470
|
}
|
|
471
471
|
if (opts.all && existsSync(top))
|
|
472
472
|
found.push(top);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { extname, join } from "node:path";
|
|
5
|
+
const LIMIT = 4;
|
|
6
|
+
const TIMEOUT_MS = 30_000;
|
|
7
|
+
/**
|
|
8
|
+
* node --check does not strip types, so .ts goes through module.stripTypeScriptTypes, which
|
|
9
|
+
* parses the whole file and throws on a syntax error. An enum or namespace parses but cannot be
|
|
10
|
+
* stripped, which is node's limit and not the file's, so that counts as parsing.
|
|
11
|
+
*/
|
|
12
|
+
const STRIP_TYPES = `
|
|
13
|
+
const { stripTypeScriptTypes } = require("node:module");
|
|
14
|
+
const { readFileSync } = require("node:fs");
|
|
15
|
+
try {
|
|
16
|
+
stripTypeScriptTypes(readFileSync(process.argv[1], "utf8"), { mode: "strip" });
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") process.exit(0);
|
|
19
|
+
console.error(error.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}`;
|
|
22
|
+
/** Runs argv with {file} replaced; a program that is not installed skips the file. */
|
|
23
|
+
function command(argv) {
|
|
24
|
+
return (file) => run(argv.map((a) => (a === "{file}" ? file : a)), file, argv[0] ?? "");
|
|
25
|
+
}
|
|
26
|
+
function run(argv, file, what) {
|
|
27
|
+
const [program, ...rest] = argv;
|
|
28
|
+
if (program === undefined)
|
|
29
|
+
return Promise.resolve({ kind: "parses" });
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(program, rest, { timeout: TIMEOUT_MS, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
32
|
+
if (error === null)
|
|
33
|
+
return resolve({ kind: "parses" });
|
|
34
|
+
if (error.code === "ENOENT") {
|
|
35
|
+
return resolve({ kind: "skipped", reason: `${what} is not installed` });
|
|
36
|
+
}
|
|
37
|
+
const output = stderr.trim() === "" ? stdout : stderr;
|
|
38
|
+
const message = error.killed === true
|
|
39
|
+
? `checker timed out after ${TIMEOUT_MS / 1000}s`
|
|
40
|
+
: output.trim() === ""
|
|
41
|
+
? `checker exited ${error.code}`
|
|
42
|
+
: complaint(output, file);
|
|
43
|
+
resolve({ kind: "fails", message });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The line of a parser's output that names the problem. node prints `file:LINE`, the source line,
|
|
49
|
+
* a caret and then `SyntaxError: ...`; python prints a traceback ending in `SyntaxError: ...`;
|
|
50
|
+
* sh and bash print one line. The temp path never leaks into the message.
|
|
51
|
+
*/
|
|
52
|
+
export function complaint(output, file) {
|
|
53
|
+
const lines = output
|
|
54
|
+
.replaceAll(file, "file")
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((l) => l.trimEnd())
|
|
57
|
+
.filter((l) => l !== "");
|
|
58
|
+
const named = lines.find((l) => /^[A-Za-z]*Error\b/.test(l));
|
|
59
|
+
if (named === undefined)
|
|
60
|
+
return lines[0] ?? "does not parse";
|
|
61
|
+
const where = /^file:(\d+)$/.exec(lines[0] ?? "")?.[1];
|
|
62
|
+
return where === undefined ? named : `line ${where}: ${named}`;
|
|
63
|
+
}
|
|
64
|
+
const json = async (_file, text) => {
|
|
65
|
+
try {
|
|
66
|
+
JSON.parse(text);
|
|
67
|
+
return { kind: "parses" };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return { kind: "fails", message: message.split("\n")[0] ?? message };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** Bash scripts need bash -n; a plain .sh is held to POSIX unless its shebang names bash. */
|
|
75
|
+
const sh = command(["sh", "-n", "{file}"]);
|
|
76
|
+
const bash = command(["bash", "-n", "{file}"]);
|
|
77
|
+
const shell = (file, text) => /^#!.*\bbash\b/.test(text) || file.endsWith(".bash") ? bash(file, text) : sh(file, text);
|
|
78
|
+
const node = command(["node", "--check", "{file}"]);
|
|
79
|
+
const types = command([
|
|
80
|
+
"node",
|
|
81
|
+
"--disable-warning=ExperimentalWarning",
|
|
82
|
+
"-e",
|
|
83
|
+
STRIP_TYPES,
|
|
84
|
+
"{file}",
|
|
85
|
+
]);
|
|
86
|
+
const python = command(["python3", "-m", "py_compile", "{file}"]);
|
|
87
|
+
/**
|
|
88
|
+
* Extension to checker. A missing extension is skipped, not guessed: .jsx and .tsx need a JSX
|
|
89
|
+
* parser node does not have, and yaml has none installed.
|
|
90
|
+
*/
|
|
91
|
+
const CHECKERS = new Map([
|
|
92
|
+
[".js", node],
|
|
93
|
+
[".mjs", node],
|
|
94
|
+
[".cjs", node],
|
|
95
|
+
[".ts", types],
|
|
96
|
+
[".mts", types],
|
|
97
|
+
[".cts", types],
|
|
98
|
+
[".json", json],
|
|
99
|
+
[".sh", shell],
|
|
100
|
+
[".bash", shell],
|
|
101
|
+
[".py", python],
|
|
102
|
+
]);
|
|
103
|
+
function quote(s) {
|
|
104
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
105
|
+
}
|
|
106
|
+
/** The --syntax override: one shell command for every text file, {file} replaced by its path. */
|
|
107
|
+
function overrideChecker(template) {
|
|
108
|
+
return (file) => run(["sh", "-c", template.replaceAll("{file}", quote(file))], file, "the --syntax command");
|
|
109
|
+
}
|
|
110
|
+
async function mapLimit(items, limit, fn) {
|
|
111
|
+
const out = new Array(items.length);
|
|
112
|
+
let next = 0;
|
|
113
|
+
const worker = async () => {
|
|
114
|
+
for (let i = next++; i < items.length; i = next++) {
|
|
115
|
+
const item = items[i];
|
|
116
|
+
if (item !== undefined)
|
|
117
|
+
out[i] = await fn(item);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function byPath(a, b) {
|
|
124
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Checks every file each commit writes. The last commit equals the tip by construction, so a
|
|
128
|
+
* file that fails there is reported as the tip's own state and is not checked in earlier commits,
|
|
129
|
+
* where the same failure would be blamed on the split.
|
|
130
|
+
*/
|
|
131
|
+
export async function checkSyntax(plan, opts) {
|
|
132
|
+
const last = plan.commits.length - 1;
|
|
133
|
+
const jobs = plan.commits.flatMap((c) => c.changes.flatMap((ch) => ch.op === "write" ? [{ commit: c.index, path: ch.path, bytes: ch.bytes }] : []));
|
|
134
|
+
// node prints the resolved path, so the dir is resolved too for the message to find it.
|
|
135
|
+
const dir = await realpath(await mkdtemp(join(tmpdir(), "follow-me-syntax-")));
|
|
136
|
+
try {
|
|
137
|
+
const cache = new Map();
|
|
138
|
+
let serial = 0;
|
|
139
|
+
const outcomeOf = (job) => {
|
|
140
|
+
const ext = extname(job.path).toLowerCase();
|
|
141
|
+
const checker = opts.override === undefined ? CHECKERS.get(ext) : overrideChecker(opts.override);
|
|
142
|
+
if (checker === undefined) {
|
|
143
|
+
return Promise.resolve({
|
|
144
|
+
kind: "skipped",
|
|
145
|
+
reason: `no checker for ${ext === "" ? "files without an extension" : ext}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const key = `${ext}\0${job.bytes}`;
|
|
149
|
+
const known = cache.get(key);
|
|
150
|
+
if (known !== undefined)
|
|
151
|
+
return known;
|
|
152
|
+
const file = join(dir, `f${serial++}${ext}`);
|
|
153
|
+
const fresh = writeFile(file, Buffer.from(job.bytes, "latin1")).then(() => checker(file, job.bytes));
|
|
154
|
+
cache.set(key, fresh);
|
|
155
|
+
return fresh;
|
|
156
|
+
};
|
|
157
|
+
const tipJobs = jobs.filter((j) => j.commit === last);
|
|
158
|
+
const tipOutcomes = await mapLimit(tipJobs, LIMIT, outcomeOf);
|
|
159
|
+
const tip = [];
|
|
160
|
+
const broken = new Set();
|
|
161
|
+
tipJobs.forEach((job, i) => {
|
|
162
|
+
const outcome = tipOutcomes[i];
|
|
163
|
+
if (outcome?.kind !== "fails")
|
|
164
|
+
return;
|
|
165
|
+
tip.push({ commit: job.commit, path: job.path, message: outcome.message });
|
|
166
|
+
broken.add(job.path);
|
|
167
|
+
});
|
|
168
|
+
const rest = jobs.filter((j) => j.commit !== last && !broken.has(j.path));
|
|
169
|
+
const outcomes = await mapLimit(rest, LIMIT, outcomeOf);
|
|
170
|
+
const issues = [];
|
|
171
|
+
const skipped = new Map();
|
|
172
|
+
let checked = 0;
|
|
173
|
+
const all = [
|
|
174
|
+
...tipJobs.map((j, i) => [j, tipOutcomes[i]]),
|
|
175
|
+
...rest.map((j, i) => [j, outcomes[i]]),
|
|
176
|
+
];
|
|
177
|
+
for (const [job, outcome] of all) {
|
|
178
|
+
if (outcome === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
if (outcome.kind === "skipped")
|
|
181
|
+
skipped.set(job.path, outcome.reason);
|
|
182
|
+
else
|
|
183
|
+
checked++;
|
|
184
|
+
if (outcome.kind === "fails" && job.commit !== last) {
|
|
185
|
+
issues.push({ commit: job.commit, path: job.path, message: outcome.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
issues.sort((a, b) => a.commit - b.commit || byPath(a, b));
|
|
189
|
+
return {
|
|
190
|
+
issues,
|
|
191
|
+
tip,
|
|
192
|
+
skipped: [...skipped].map(([path, reason]) => ({ path, reason })).sort(byPath),
|
|
193
|
+
checked,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await rm(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/src/types.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* project every commit, prove the last one equals the tip. Pure.
|
|
9
9
|
* git.ts the only module that spawns git: load a Diff, write a branch from a ValidPlan.
|
|
10
10
|
* session.ts the .follow-me/<slug>/ layout: session anchor, plans, reviews, draft.
|
|
11
|
-
*
|
|
11
|
+
* comments.ts the wire formats of review state: comments, replies, concern decisions, the
|
|
12
|
+
* review file, the autosaved draft and the server's events. Pure parsers.
|
|
13
|
+
* prompt.ts the planner prompt for an agent, the feedback and the consolidated report.
|
|
12
14
|
* server.ts the review server: a registry of hosted sessions, their JSON API, and ui/.
|
|
13
15
|
* host.ts the one server per user: its record in the state dir, attach, start, stop.
|
|
14
16
|
* cli.ts argv -> one call into the modules above.
|