severally-mcp 2.3.1 → 2.4.2

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.
Files changed (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -22,7 +22,58 @@ import { spawnSync } from 'node:child_process';
22
22
  import { POLICY, isProtectedPath } from './policy.mjs';
23
23
  import { redact } from './redact.mjs';
24
24
 
25
+ /** @typedef {{ files: number, bytes: number }} ExposeTotals */
26
+
27
+ /**
28
+ * A path left out of the copy, with why: 'ignored', 'symlink', 'protected',
29
+ * 'submodule', 'gone', 'masked' or 'replaced'. `exposed_as` names it where a
30
+ * follow-up carries over an earlier round's entry.
31
+ * @typedef {{ source: string, reason: string, exposed_as?: string | undefined }} SkippedPath
32
+ */
33
+
34
+ /**
35
+ * One exposed entry as the walk and the copy report it. `kind` is 'file',
36
+ * 'directory', 'diff' or 'change_files'.
37
+ * @typedef {object} ExposeEntry
38
+ * @property {number} index
39
+ * @property {string} source
40
+ * @property {string} kind
41
+ * @property {string} exposedAs
42
+ * @property {number} files
43
+ * @property {number} bytes
44
+ * @property {string[]} paths
45
+ * @property {string | null} [sha256]
46
+ * @property {string | undefined} [base]
47
+ * @property {string[]} [changes]
48
+ * @property {boolean} [changed] a follow-up's copy of a parent path: whether its bytes changed since
49
+ * @property {boolean} [inherited] a follow-up copied it again from the parent round
50
+ */
51
+
52
+ /**
53
+ * What an expose_paths request comes to: the entries, their totals, and what was left out.
54
+ * @typedef {{ entries: ExposeEntry[], totals: ExposeTotals, skipped: SkippedPath[] }} ExposeManifest
55
+ */
56
+
57
+ /** A path as the lead names it, or as an earlier round numbered it. @typedef {string | { source: string, index: number }} ExposePathSpec */
58
+
59
+ /**
60
+ * An entry of an earlier round's brief record (briefRecord's `expose_paths.entries`).
61
+ * @typedef {object} ExposedEntryRecord
62
+ * @property {number} index
63
+ * @property {string} source
64
+ * @property {string | undefined} [exposed_as]
65
+ * @property {string | undefined} [kind]
66
+ * @property {string | undefined} [base]
67
+ * @property {string[] | undefined} [changes]
68
+ */
69
+
70
+ /** @typedef {{ path: string, status: string }} RepoChange */
71
+
25
72
  export class ExposeError extends Error {
73
+ /**
74
+ * @param {string} message
75
+ * @param {string} code
76
+ */
26
77
  constructor(message, code) {
27
78
  super(message);
28
79
  this.name = 'ExposeError';
@@ -30,7 +81,13 @@ export class ExposeError extends Error {
30
81
  }
31
82
  }
32
83
 
33
- /** A size a reader can judge at a glance; the brief is read, not parsed. */
84
+ /** @param {unknown} err */
85
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
86
+
87
+ /**
88
+ * A size a reader can judge at a glance; the brief is read, not parsed.
89
+ * @param {number} bytes
90
+ */
34
91
  export function humanSize(bytes) {
35
92
  if (bytes < 1024) return `${bytes} B`;
36
93
  const kb = bytes / 1024;
@@ -42,6 +99,7 @@ export function humanSize(bytes) {
42
99
  // Getting it wrong is survivable in one direction only -- a binary treated as
43
100
  // text would be corrupted by redact()'s utf8 round-trip, so the test errs
44
101
  // toward calling things binary.
102
+ /** @param {Uint8Array} buf */
45
103
  function isBinary(buf) {
46
104
  const len = Math.min(buf.length, 8_000);
47
105
  for (let i = 0; i < len; i++) if (buf[i] === 0) return true;
@@ -50,22 +108,27 @@ function isBinary(buf) {
50
108
 
51
109
  // Before a file is read: the caps are a few megabytes, and a 1 GB file must
52
110
  // be refused by its size, not after it has been read into memory and written.
111
+ /**
112
+ * @param {ExposeTotals} totals
113
+ * @param {number} size
114
+ */
53
115
  function ensureRoom(totals, size) {
54
116
  checkBudget({ files: totals.files + 1, bytes: totals.bytes + size });
55
117
  }
56
118
 
119
+ /** @param {ExposeTotals} totals */
57
120
  function checkBudget(totals) {
58
121
  if (totals.files > POLICY.input.exposeFilesMax) {
59
122
  throw new ExposeError(
60
- `expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or `
61
- + 'the subdirectory that actually matters instead of the tree above them',
123
+ `expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or ` +
124
+ 'the subdirectory that actually matters instead of the tree above them',
62
125
  'too_large',
63
126
  );
64
127
  }
65
128
  if (totals.bytes > POLICY.input.exposeBytesMax) {
66
129
  throw new ExposeError(
67
- `expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files `
68
- + 'or the subdirectory that actually matters instead of the tree above them',
130
+ `expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files ` +
131
+ 'or the subdirectory that actually matters instead of the tree above them',
69
132
  'too_large',
70
133
  );
71
134
  }
@@ -74,12 +137,13 @@ function checkBudget(totals) {
74
137
  // A path the lead named, as opposed to one found while walking: it is refused
75
138
  // rather than skipped, because a lead who typed a symlink meant something by
76
139
  // it and should hear which entry could not be used.
140
+ /** @param {string} source */
77
141
  function statEntry(source) {
78
142
  let stat;
79
143
  try {
80
144
  stat = fs.lstatSync(source);
81
145
  } catch (err) {
82
- throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${err.message}`, 'not_found');
146
+ throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${messageOf(err)}`, 'not_found');
83
147
  }
84
148
  if (stat.isSymbolicLink()) {
85
149
  throw new ExposeError(`expose_paths entry "${source}" is a symlink; name the path it points at instead`, 'invalid_type');
@@ -94,6 +158,13 @@ function statEntry(source) {
94
158
  // accounts for the file (it owns the totals), and the budget is checked after
95
159
  // every single one, so an oversized tree stops at the file that crossed the
96
160
  // line rather than after the whole thing has been walked or copied.
161
+ /**
162
+ * @param {string} root
163
+ * @param {string} dir
164
+ * @param {(abs: string, rel: string) => void} onFile
165
+ * @param {ExposeTotals} totals
166
+ * @param {SkippedPath[]} skipped
167
+ */
97
168
  function walkDir(root, dir, onFile, totals, skipped) {
98
169
  let items;
99
170
  try {
@@ -101,7 +172,7 @@ function walkDir(root, dir, onFile, totals, skipped) {
101
172
  // entries were created.
102
173
  items = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
103
174
  } catch (err) {
104
- throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${err.message}`, 'not_found');
175
+ throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${messageOf(err)}`, 'not_found');
105
176
  }
106
177
  for (const item of items) {
107
178
  const child = path.join(dir, item.name);
@@ -138,10 +209,18 @@ function walkDir(root, dir, onFile, totals, skipped) {
138
209
 
139
210
  // walkDir's order: name by name at every level, not the whole path as one
140
211
  // string ("a/x" comes before "a-b" there, after it as a string).
212
+ /**
213
+ * @param {string} a
214
+ * @param {string} b
215
+ */
141
216
  function byPathParts(a, b) {
142
217
  const x = a.split('/');
143
218
  const y = b.split('/');
144
- for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
219
+ for (const [i, part] of x.entries()) {
220
+ const other = y[i];
221
+ if (other === undefined) break;
222
+ if (part !== other) return part < other ? -1 : 1;
223
+ }
145
224
  return x.length - y.length;
146
225
  }
147
226
 
@@ -152,13 +231,23 @@ function byPathParts(a, b) {
152
231
  // submodule, or reach outside the directory. False when this is not a work
153
232
  // tree, git is missing or lists nothing (an ignored directory the lead named
154
233
  // on purpose): the caller walks it instead.
234
+ /**
235
+ * @param {string} dir
236
+ * @param {string} exposedAs
237
+ * @param {(abs: string, rel: string) => void} onFile
238
+ * @param {ExposeTotals} totals
239
+ * @param {SkippedPath[]} skipped
240
+ * @returns {boolean}
241
+ */
155
242
  function walkGit(dir, exposedAs, onFile, totals, skipped) {
156
243
  let listed;
157
244
  let ignored;
158
245
  try {
159
246
  listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
160
247
  ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
161
- } catch { return false; }
248
+ } catch {
249
+ return false;
250
+ }
162
251
  if (listed.length === 0) return false;
163
252
  for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
164
253
  if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
@@ -169,21 +258,43 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
169
258
  // walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
170
259
  // listed file is checked with each directory above it for the same reason.
171
260
  const parts = rel.split('/');
172
- const guarded = parts.slice(0, -1).map((_, i) => path.join(dir, ...parts.slice(0, i + 1))).find(isProtectedPath);
261
+ const guarded = parts
262
+ .slice(0, -1)
263
+ .map((_, i) => path.join(dir, ...parts.slice(0, i + 1)))
264
+ .find(isProtectedPath);
173
265
  if (guarded) {
174
266
  if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
175
267
  protectedDirs.add(guarded);
176
268
  continue;
177
269
  }
178
- if (isProtectedPath(abs)) { skipped.push({ source: abs, reason: 'protected' }); continue; }
270
+ if (isProtectedPath(abs)) {
271
+ skipped.push({ source: abs, reason: 'protected' });
272
+ continue;
273
+ }
179
274
  let stat;
180
275
  // Deleted in the work tree but still in the index: not part of the tree
181
276
  // as it is now, so not part of the copy either.
182
- try { stat = fs.lstatSync(abs); } catch { continue; }
277
+ try {
278
+ stat = fs.lstatSync(abs);
279
+ } catch {
280
+ continue;
281
+ }
183
282
  // A directory here is a submodule (or a nested repository): its own history, not this one's files.
184
- if (stat.isDirectory()) { skipped.push({ source: abs, reason: 'submodule' }); continue; }
185
- const inside = (() => { try { return fs.realpathSync(abs).startsWith(realDir + path.sep); } catch { return false; } })();
186
- if (stat.isSymbolicLink() || !inside) { skipped.push({ source: abs, reason: 'symlink' }); continue; }
283
+ if (stat.isDirectory()) {
284
+ skipped.push({ source: abs, reason: 'submodule' });
285
+ continue;
286
+ }
287
+ const inside = (() => {
288
+ try {
289
+ return fs.realpathSync(abs).startsWith(realDir + path.sep);
290
+ } catch {
291
+ return false;
292
+ }
293
+ })();
294
+ if (stat.isSymbolicLink() || !inside) {
295
+ skipped.push({ source: abs, reason: 'symlink' });
296
+ continue;
297
+ }
187
298
  if (!stat.isFile()) continue;
188
299
  onFile(abs, path.normalize(rel));
189
300
  checkBudget(totals);
@@ -193,8 +304,14 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
193
304
 
194
305
  // Each path is a string, or { source, index } to keep the name an earlier
195
306
  // round gave it.
307
+ /**
308
+ * @param {ExposePathSpec[]} exposePaths
309
+ * @param {(abs: string, rel: string, index: number, source: string, totals: ExposeTotals) => void} onFile
310
+ * @returns {ExposeManifest}
311
+ */
196
312
  function walkEntries(exposePaths, onFile) {
197
313
  const totals = { files: 0, bytes: 0 };
314
+ /** @type {SkippedPath[]} */
198
315
  const skipped = [];
199
316
  const entries = exposePaths.map((p, i) => {
200
317
  const { source, index } = typeof p === 'string' ? { source: p, index: i } : p;
@@ -205,6 +322,7 @@ function walkEntries(exposePaths, onFile) {
205
322
  const before = { files: totals.files, bytes: totals.bytes };
206
323
  // What a directory holds, for the brief: not every consultant has a tool
207
324
  // that lists a directory, and a name it cannot see is a file it never reads.
325
+ /** @type {string[]} */
208
326
  const paths = [];
209
327
  if (isProtectedPath(source)) {
210
328
  skipped.push({ source, reason: 'protected' });
@@ -212,6 +330,7 @@ function walkEntries(exposePaths, onFile) {
212
330
  onFile(source, '', index, source, totals);
213
331
  checkBudget(totals);
214
332
  } else {
333
+ /** @type {(abs: string, rel: string) => void} */
215
334
  const add = (abs, rel) => {
216
335
  paths.push(rel.split(path.sep).join('/'));
217
336
  onFile(abs, rel, index, source, totals);
@@ -237,6 +356,8 @@ function walkEntries(exposePaths, onFile) {
237
356
  * real copy may write slightly fewer bytes once a text file's
238
357
  * credential-shaped strings are masked, which only ever shrinks what was
239
358
  * already reported.
359
+ * @param {ExposePathSpec[] | null | undefined} exposePaths
360
+ * @returns {ExposeManifest | null}
240
361
  */
241
362
  export function previewExposePaths(exposePaths) {
242
363
  if (!exposePaths || exposePaths.length === 0) return null;
@@ -253,11 +374,15 @@ export function previewExposePaths(exposePaths) {
253
374
  * are copied unchanged, since a utf8 round-trip would corrupt them. Walks
254
375
  * from scratch and re-checks the caps, so a tree that grew or vanished since
255
376
  * the preview fails here instead of being copied in part.
377
+ * @param {string} workdir
378
+ * @param {ExposePathSpec[] | null | undefined} exposePaths
379
+ * @returns {ExposeManifest | null}
256
380
  */
257
381
  export function materializeExposedPaths(workdir, exposePaths) {
258
382
  if (!exposePaths || exposePaths.length === 0) return null;
259
383
  const root = path.join(workdir, 'workspace');
260
384
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
385
+ /** @type {import('node:crypto').Hash[]} */
261
386
  const hashes = [];
262
387
  const manifest = walkEntries(exposePaths, (abs, rel, index, source, totals) => {
263
388
  ensureRoom(totals, fs.statSync(abs).size);
@@ -273,6 +398,10 @@ export function materializeExposedPaths(workdir, exposePaths) {
273
398
 
274
399
  // The bytes a consultant gets for one file, written to dest: valid-utf8 text
275
400
  // through redact(), anything else unchanged.
401
+ /**
402
+ * @param {string} abs
403
+ * @param {string} dest
404
+ */
276
405
  function copyMasked(abs, dest) {
277
406
  const buf = fs.readFileSync(abs);
278
407
  let out = buf;
@@ -295,27 +424,59 @@ function copyMasked(abs, dest) {
295
424
  // sha256 covers the copies in path order, so the same files give the same
296
425
  // hash however they were found. `gone` is where a path that no longer exists
297
426
  // goes; by default it is dropped, as a change listing a vanished path is.
427
+ /**
428
+ * @param {string} workdir
429
+ * @param {string} top
430
+ * @param {RepoChange[]} changes
431
+ * @param {ExposeManifest} manifest
432
+ * @param {(c: RepoChange) => void} shown
433
+ * @param {((c: RepoChange) => void) | null} [gone]
434
+ */
298
435
  function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
299
436
  const root = path.join(workdir, 'workspace', 'repo');
300
437
  // Made even when nothing is copied (a change that only deletes): each
301
438
  // member's workspace is copied from here.
302
439
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
303
440
  const realTop = fs.realpathSync(top);
441
+ /** @type {{ path: string, size: number }[]} */
304
442
  const copied = [];
305
443
  for (const c of changes) {
306
444
  const abs = path.join(top, c.path);
307
- if (isProtectedPath(abs)) { manifest.skipped.push({ source: abs, reason: 'protected' }); continue; }
308
- if (c.status === 'D') { shown(c); continue; }
445
+ if (isProtectedPath(abs)) {
446
+ manifest.skipped.push({ source: abs, reason: 'protected' });
447
+ continue;
448
+ }
449
+ if (c.status === 'D') {
450
+ shown(c);
451
+ continue;
452
+ }
309
453
  let stat;
310
454
  // When `gone` is given, the paths are an earlier round's, and a skipped
311
455
  // one carries the name it had there.
312
456
  const named = gone ? { exposed_as: `workspace/repo/${c.path}` } : {};
313
- try { stat = fs.lstatSync(abs); } catch { gone?.(c); continue; }
457
+ try {
458
+ stat = fs.lstatSync(abs);
459
+ } catch {
460
+ gone?.(c);
461
+ continue;
462
+ }
314
463
  // A directory on the way that is now a link out of the repository.
315
- const inside = (() => { try { return fs.realpathSync(abs).startsWith(realTop + path.sep); } catch { return false; } })();
316
- if (stat.isSymbolicLink() || !inside) { manifest.skipped.push({ source: abs, reason: 'symlink', ...named }); continue; }
464
+ const inside = (() => {
465
+ try {
466
+ return fs.realpathSync(abs).startsWith(realTop + path.sep);
467
+ } catch {
468
+ return false;
469
+ }
470
+ })();
471
+ if (stat.isSymbolicLink() || !inside) {
472
+ manifest.skipped.push({ source: abs, reason: 'symlink', ...named });
473
+ continue;
474
+ }
317
475
  // A submodule: its commit line stays in the diff, nothing to copy.
318
- if (!stat.isFile()) { shown(c); continue; }
476
+ if (!stat.isFile()) {
477
+ shown(c);
478
+ continue;
479
+ }
319
480
  shown(c);
320
481
  copied.push({ path: c.path, size: stat.size });
321
482
  }
@@ -341,24 +502,49 @@ function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
341
502
  * ('masked'). The parent's diff is not taken again: the files it touched are
342
503
  * re-copied as they are now (`diffs`, for reexposeChange), because a diff
343
504
  * against a base that has since moved would be a different change.
505
+ * @param {ExposedEntryRecord[]} parentEntries
506
+ * @param {string[]} exposePaths
344
507
  */
345
508
  export function planInheritance(parentEntries, exposePaths) {
509
+ /** @type {{ source: string, index: number }[]} */
346
510
  const paths = [];
511
+ /** @type {SkippedPath[]} */
347
512
  const skipped = [];
513
+ /** @type {ExposedEntryRecord[]} */
348
514
  const diffs = [];
349
- const exists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
515
+ /** @param {string} p */
516
+ const exists = (p) => {
517
+ try {
518
+ fs.lstatSync(p);
519
+ return true;
520
+ } catch {
521
+ return false;
522
+ }
523
+ };
350
524
  for (const e of parentEntries) {
351
- if (String(e.source).includes('[REDACTED')) { skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as }); continue; }
352
- if (!exists(e.source)) { skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as }); continue; }
353
- if (e.kind === 'diff' || e.kind === 'change_files') { diffs.push(e); continue; }
525
+ if (String(e.source).includes('[REDACTED')) {
526
+ skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as });
527
+ continue;
528
+ }
529
+ if (!exists(e.source)) {
530
+ skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as });
531
+ continue;
532
+ }
533
+ if (e.kind === 'diff' || e.kind === 'change_files') {
534
+ diffs.push(e);
535
+ continue;
536
+ }
354
537
  paths.push({ source: e.source, index: e.index });
355
538
  }
356
539
  // New paths are numbered after every name the earlier round used.
357
540
  let next = parentEntries.reduce((n, e) => Math.max(n, e.index + 1), 0);
358
541
  for (const p of exposePaths) if (!paths.some((x) => x.source === p)) paths.push({ source: p, index: next++ });
359
542
  if (paths.length > POLICY.input.exposePathsMax) {
360
- throw new ExposeError(`expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of `
361
- + `${POLICY.input.exposePathsMax}; name fewer new paths`, 'too_large');
543
+ throw new ExposeError(
544
+ `expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of ` +
545
+ `${POLICY.input.exposePathsMax}; name fewer new paths`,
546
+ 'too_large',
547
+ );
362
548
  }
363
549
  return { paths, skipped, diffs };
364
550
  }
@@ -367,27 +553,53 @@ export function planInheritance(parentEntries, exposePaths) {
367
553
  * The files an earlier round's change touched, copied again under
368
554
  * workspace/repo/ as they are now, added to `manifest` as a 'change_files'
369
555
  * entry. Read from that round's record of the change, not from git.
556
+ * @param {string} workdir
557
+ * @param {ExposedEntryRecord} entry
558
+ * @param {ExposeManifest} manifest
559
+ * @returns {boolean}
370
560
  */
371
561
  export function reexposeChange(workdir, entry, manifest) {
562
+ /** @type {RepoChange[]} */
372
563
  const changes = [];
373
564
  for (const c of entry.changes ?? []) {
374
565
  const m = /^(.*) \(([^()]+)\)$/.exec(c);
375
- const p = m ? m[1] : c;
376
- if (p.includes('[REDACTED')) { manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` }); continue; }
566
+ const p = m?.[1] ?? c;
567
+ if (p.includes('[REDACTED')) {
568
+ manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` });
569
+ continue;
570
+ }
377
571
  // A file the change deleted that is back now is copied as it is.
378
572
  const deleted = m?.[2] === 'deleted';
379
573
  let back = false;
380
- if (deleted) try { fs.lstatSync(path.join(entry.source, p)); back = true; } catch { /* still deleted */ }
574
+ if (deleted)
575
+ try {
576
+ fs.lstatSync(path.join(entry.source, p));
577
+ back = true;
578
+ } catch {
579
+ /* still deleted */
580
+ }
381
581
  changes.push({ path: p, status: deleted ? (back ? 'restored' : 'D') : (m?.[2] ?? 'M') });
382
582
  }
383
583
  const before = { ...manifest.totals };
584
+ /** @type {RepoChange[]} */
384
585
  const shown = [];
385
- const { copied, sha256 } = copyRepoFiles(workdir, entry.source, changes, manifest, (c) => shown.push(c),
386
- (c) => manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }));
586
+ const { copied, sha256 } = copyRepoFiles(
587
+ workdir,
588
+ entry.source,
589
+ changes,
590
+ manifest,
591
+ (c) => shown.push(c),
592
+ (c) =>
593
+ manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }),
594
+ );
387
595
  // Nothing of the change is left to show: no entry, so a follow-up with
388
596
  // nothing else to go on is refused rather than sent with an empty copy.
389
597
  if (shown.length === 0) {
390
- try { fs.rmdirSync(path.join(workdir, 'workspace', 'repo')); } catch { /* holds another entry's files */ }
598
+ try {
599
+ fs.rmdirSync(path.join(workdir, 'workspace', 'repo'));
600
+ } catch {
601
+ /* holds another entry's files */
602
+ }
391
603
  return false;
392
604
  }
393
605
  manifest.entries.push({
@@ -410,20 +622,31 @@ export function reexposeChange(workdir, entry, manifest) {
410
622
  // switched off, pathspecs are literal, and no shell is involved.
411
623
  // ponytail: a clean filter (filter.<driver>.clean in .git/config) still runs
412
624
  // on `git diff` of the working tree; .git/config is local, not cloned.
625
+ /**
626
+ * @param {string} cwd
627
+ * @param {string[]} args
628
+ */
413
629
  function git(cwd, args) {
414
630
  // Without the server's own GIT_* (GIT_DIR, GIT_INDEX_FILE ... would point
415
631
  // at another repository), and without taking the lead's index lock.
416
632
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('GIT_')));
417
633
  const r = spawnSync('git', ['--literal-pathspecs', '-c', 'core.fsmonitor=', '-C', cwd, ...args], {
418
- timeout: 20_000, maxBuffer: 64 * 1024 * 1024, windowsHide: true, env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
634
+ timeout: 20_000,
635
+ maxBuffer: 64 * 1024 * 1024,
636
+ windowsHide: true,
637
+ env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
419
638
  });
420
639
  if (r.error || r.status !== 0) {
421
- const why = r.error?.code === 'ENOENT' ? 'git is not on PATH' : String(r.stderr ?? r.error?.message ?? '').trim();
640
+ const why =
641
+ r.error && 'code' in r.error && r.error.code === 'ENOENT'
642
+ ? 'git is not on PATH'
643
+ : String(r.stderr ?? r.error?.message ?? '').trim();
422
644
  throw new ExposeError(`context.expose_diff: git ${args[0]} failed in "${cwd}": ${why}`, 'not_found');
423
645
  }
424
646
  return r.stdout;
425
647
  }
426
648
 
649
+ /** @param {Buffer} buf */
427
650
  const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
428
651
 
429
652
  /**
@@ -433,22 +656,38 @@ const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
433
656
  * name the copies; deleted files are listed, not copied. Protected paths are
434
657
  * left out of the copy and of the diff. Returns the diff text, masked and cut
435
658
  * to maxChars, for the brief.
659
+ * @param {string} workdir
660
+ * @param {{ repo: string, base?: string | undefined }} source
661
+ * @param {ExposeManifest} manifest
662
+ * @param {number} maxChars
663
+ * @returns {string}
436
664
  */
437
665
  export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars) {
438
666
  const top = git(repo, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
439
667
  git(top, ['rev-parse', '--verify', `${base}^{commit}`]);
440
- const tokens = nulSplit(git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff',
441
- '--no-textconv', base, '--']));
668
+ const tokens = nulSplit(
669
+ git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff', '--no-textconv', base, '--']),
670
+ );
671
+ /** @type {RepoChange[]} */
442
672
  const changes = [];
443
- for (let i = 0; i + 1 < tokens.length; i += 2) changes.push({ status: tokens[i], path: tokens[i + 1] });
444
- for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z']))) changes.push({ status: 'untracked', path: p });
673
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
674
+ const status = tokens[i];
675
+ const file = tokens[i + 1];
676
+ if (status !== undefined && file !== undefined) changes.push({ status, path: file });
677
+ }
678
+ for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z'])))
679
+ changes.push({ status: 'untracked', path: p });
445
680
 
446
681
  const before = { ...manifest.totals };
682
+ /** @type {RepoChange[]} */
447
683
  const shown = [];
448
684
  const { copied, sha256 } = copyRepoFiles(workdir, top, changes, manifest, (c) => shown.push(c));
449
685
  if (shown.length === 0) {
450
- throw new ExposeError(`context.expose_diff: no change against ${base} to show`
451
- + (changes.length ? ' (every changed file is a protected path)' : ''), 'not_found');
686
+ throw new ExposeError(
687
+ `context.expose_diff: no change against ${base} to show` +
688
+ (changes.length ? ' (every changed file is a protected path)' : ''),
689
+ 'not_found',
690
+ );
452
691
  }
453
692
  manifest.entries.push({
454
693
  index: manifest.entries.reduce((n, e) => Math.max(n, e.index + 1), 0),
@@ -461,7 +700,9 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
461
700
  // The copies, as paths a consultant can open; the change itself, with
462
701
  // what happened to each path, is listed apart.
463
702
  paths: copied,
464
- changes: shown.map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`).sort(),
703
+ changes: shown
704
+ .map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`)
705
+ .sort(),
465
706
  sha256,
466
707
  });
467
708
 
@@ -474,18 +715,37 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
474
715
  // without its old content.
475
716
  const tracked = new Set(shown.filter((c) => c.status !== 'untracked').map((c) => c.path));
476
717
  const full = tracked.size
477
- ? git(top, ['diff', '--no-ext-diff', '--no-textconv', '--no-color', '--src-prefix=a/', '--dst-prefix=b/',
478
- '--no-relative', '--submodule=short', '--irreversible-delete', base, '--']).toString('utf8')
718
+ ? git(top, [
719
+ 'diff',
720
+ '--no-ext-diff',
721
+ '--no-textconv',
722
+ '--no-color',
723
+ '--src-prefix=a/',
724
+ '--dst-prefix=b/',
725
+ '--no-relative',
726
+ '--submodule=short',
727
+ '--irreversible-delete',
728
+ base,
729
+ '--',
730
+ ]).toString('utf8')
479
731
  : '';
480
732
  // A section whose header names no shown path (a protected one, or one git
481
733
  // had to quote) is dropped rather than guessed at.
482
- let text = redact(full.split(/^(?=diff --git )/m).filter((sec) => {
483
- const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
484
- const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
485
- return tracked.has(p) && rest === `a/${p} b/${p}`;
486
- }).join(''));
734
+ let text = redact(
735
+ full
736
+ .split(/^(?=diff --git )/m)
737
+ .filter((sec) => {
738
+ const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
739
+ const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
740
+ return tracked.has(p) && rest === `a/${p} b/${p}`;
741
+ })
742
+ .join(''),
743
+ );
487
744
  if (text.length > maxChars) {
488
- text = maxChars < 500 ? '' : `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
745
+ text =
746
+ maxChars < 500
747
+ ? ''
748
+ : `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
489
749
  }
490
750
  return text;
491
751
  }