@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.
@@ -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 { isRepoPath, isSha, isUnitId } from "./diff.js";
5
- import { branchExists, currentBranch, defaultBaseRef, loadDiff, objectsDir, QUARANTINE_PREFIX, repoRoot, resolveCommit, } from "./git.js";
6
- import { isArray, isCommentId, isFields, isPlanVersion, parsePlanFile, planHash, serializePlanFile, } from "./plan.js";
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 writeJson(path, value) {
137
+ function writeText(path, text) {
122
138
  mkdirSync(dirname(path), { recursive: true });
123
139
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
124
- writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
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
- return { schema, version, branch, head };
178
- }
179
- function isCommitIndex(v) {
180
- return typeof v === "number" && Number.isInteger(v) && v >= 0;
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 reviewsDir = join(dir, "reviews");
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 reviews = () => versions(reviewsDir).map(({ version, path }) => {
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
- lastReview: () => reviews().findLast((r) => r.review.mode === "split")?.review ?? null,
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 SessionFileError(`${draftPath} does not exist yet`);
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: (version, result) => writeJson(appliedPath, {
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 repoRoot(repo);
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(dir) {
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 repoRoot(repo);
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
- found.push(...abandonedTemps(dir), ...deadReviewDrafts(dir));
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
- * prompt.ts the planner prompt for an agent, and the Anthropic API planner.
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.