severally-mcp 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +17 -7
- package/README.md +17 -7
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +13 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +10669 -10404
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +259 -86
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +72 -24
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +320 -152
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +97 -27
- package/src/redact.mjs +38 -14
- package/src/run.mjs +67 -18
- package/src/schema.mjs +50 -18
- package/src/server.mjs +134 -72
- package/src/status.mjs +81 -12
- package/src/store.mjs +131 -29
package/src/consultants.mjs
CHANGED
|
@@ -26,7 +26,11 @@ const table = (entries) => Object.freeze(Object.assign(Object.create(null), entr
|
|
|
26
26
|
// deliberately absent: they name model families, and a Claude model hosted on
|
|
27
27
|
// OpenCode should answer to "claude" too.
|
|
28
28
|
export const CLI_ALIASES = table({
|
|
29
|
-
codex: 'codex',
|
|
29
|
+
codex: 'codex',
|
|
30
|
+
'claude-code': 'claude-code',
|
|
31
|
+
antigravity: 'antigravity',
|
|
32
|
+
agy: 'antigravity',
|
|
33
|
+
opencode: 'opencode',
|
|
30
34
|
});
|
|
31
35
|
|
|
32
36
|
// Names that mean "that vendor's consultants, on any CLI".
|
|
@@ -34,7 +38,12 @@ export const VENDOR_WORDS = table({ openai: 'openai', chatgpt: 'openai', anthrop
|
|
|
34
38
|
|
|
35
39
|
// The lineage of a model, read off the first word of its stem.
|
|
36
40
|
const STEM_VENDORS = table({
|
|
37
|
-
gpt: 'openai',
|
|
41
|
+
gpt: 'openai',
|
|
42
|
+
claude: 'anthropic',
|
|
43
|
+
gemini: 'google',
|
|
44
|
+
glm: 'zai',
|
|
45
|
+
deepseek: 'deepseek',
|
|
46
|
+
nemotron: 'nvidia',
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
// Built-in "higher first" orders, each used only inside one model family: an
|
|
@@ -53,13 +62,19 @@ export const RESERVED_NAMES = Object.freeze(['all']);
|
|
|
53
62
|
/** Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code". */
|
|
54
63
|
export function normalizeName(raw) {
|
|
55
64
|
if (typeof raw !== 'string') return raw;
|
|
56
|
-
return raw
|
|
65
|
+
return raw
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.replace(/[\s_]+/g, '-');
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/** "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model. */
|
|
60
72
|
export function modelWords(model) {
|
|
61
73
|
const s = String(model ?? '').toLowerCase();
|
|
62
|
-
return s
|
|
74
|
+
return s
|
|
75
|
+
.slice(s.lastIndexOf('/') + 1)
|
|
76
|
+
.split(/[-._#:\s]+/)
|
|
77
|
+
.filter(Boolean);
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
const isDigitWord = (w) => /^v?\d+$/.test(w);
|
|
@@ -89,8 +104,7 @@ export function vendorOf(model, explicit = null) {
|
|
|
89
104
|
return STEM_VENDORS[first] ?? null;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
const containsRun = (hay, needle) => needle.length > 0
|
|
93
|
-
&& hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
|
|
107
|
+
const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
|
|
94
108
|
|
|
95
109
|
/** Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no. */
|
|
96
110
|
export function matchesWords(model, wanted) {
|
|
@@ -221,7 +235,10 @@ export function buildConsultants(config) {
|
|
|
221
235
|
const global = [];
|
|
222
236
|
const broken = {};
|
|
223
237
|
const brokenClis = {};
|
|
224
|
-
const fault = (msg) => {
|
|
238
|
+
const fault = (msg) => {
|
|
239
|
+
problems.push(msg);
|
|
240
|
+
global.push(msg);
|
|
241
|
+
};
|
|
225
242
|
const cfg = isObject(config) ? config : {};
|
|
226
243
|
if (!isObject(config)) fault('the config file must hold a JSON object');
|
|
227
244
|
for (const key of Object.keys(cfg)) {
|
|
@@ -236,7 +253,8 @@ export function buildConsultants(config) {
|
|
|
236
253
|
|
|
237
254
|
let ranking = [];
|
|
238
255
|
if (cfg.ranking !== undefined) {
|
|
239
|
-
if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== ''))
|
|
256
|
+
if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== ''))
|
|
257
|
+
ranking = cfg.ranking.map((r) => r.trim());
|
|
240
258
|
else fault('ranking must be an array of non-empty strings');
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -244,19 +262,32 @@ export function buildConsultants(config) {
|
|
|
244
262
|
if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
|
|
245
263
|
else {
|
|
246
264
|
for (const [id, def] of Object.entries(cfg.clis)) {
|
|
247
|
-
if (!CLI_IDS.includes(id)) {
|
|
265
|
+
if (!CLI_IDS.includes(id)) {
|
|
266
|
+
problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
248
269
|
const before = problems.length;
|
|
249
|
-
if (!isObject(def)) {
|
|
250
|
-
|
|
270
|
+
if (!isObject(def)) {
|
|
271
|
+
problems.push(`clis.${id} must be an object`);
|
|
272
|
+
brokenClis[id] = problems.slice(before);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
for (const k of Object.keys(def))
|
|
276
|
+
if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
|
|
251
277
|
// A wrong type must not fall back to a default: enabled: "false"
|
|
252
278
|
// read as "unset" would turn on the CLI the operator meant to stop.
|
|
253
|
-
if (def.enabled !== undefined && typeof def.enabled !== 'boolean')
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
279
|
+
if (def.enabled !== undefined && typeof def.enabled !== 'boolean')
|
|
280
|
+
problems.push(`clis.${id}.enabled: must be true or false`);
|
|
281
|
+
for (const k of ['bin', 'note'])
|
|
282
|
+
if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`clis.${id}.${k}: must be a string`);
|
|
283
|
+
if (def.args !== undefined && !(Array.isArray(def.args) && def.args.every((a) => typeof a === 'string')))
|
|
284
|
+
problems.push(`clis.${id}.args: must be an array of strings`);
|
|
285
|
+
if (def.env !== undefined && !(isObject(def.env) && Object.values(def.env).every((v) => typeof v === 'string')))
|
|
286
|
+
problems.push(`clis.${id}.env: must be an object of strings`);
|
|
257
287
|
if (def.provider !== undefined) {
|
|
258
288
|
if (id !== 'opencode') problems.push(`clis.${id}.provider: only OpenCode takes provider definitions`);
|
|
259
|
-
else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject)))
|
|
289
|
+
else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject)))
|
|
290
|
+
problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
|
|
260
291
|
}
|
|
261
292
|
if (problems.length > before) brokenClis[id] = problems.slice(before);
|
|
262
293
|
}
|
|
@@ -272,11 +303,22 @@ export function buildConsultants(config) {
|
|
|
272
303
|
const seen = new Map();
|
|
273
304
|
for (const [name, def] of Object.entries(cfg.consultants)) {
|
|
274
305
|
const at = `consultants.${name}`;
|
|
275
|
-
if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) {
|
|
276
|
-
|
|
306
|
+
if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) {
|
|
307
|
+
problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (RESERVED_NAMES.includes(name)) {
|
|
311
|
+
problems.push(`${at}: "${name}" is reserved`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
277
314
|
const before = problems.length;
|
|
278
|
-
if (!isObject(def)) {
|
|
279
|
-
|
|
315
|
+
if (!isObject(def)) {
|
|
316
|
+
problems.push(`${at} must be an object`);
|
|
317
|
+
broken[name] = problems.slice(before);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
for (const k of Object.keys(def))
|
|
321
|
+
if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
|
|
280
322
|
if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
|
|
281
323
|
if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
|
|
282
324
|
for (const k of ['effort', 'vendor', 'note']) {
|
|
@@ -284,12 +326,18 @@ export function buildConsultants(config) {
|
|
|
284
326
|
}
|
|
285
327
|
if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
|
|
286
328
|
for (const k of ['timeout_ms', 'max_budget_usd']) {
|
|
287
|
-
if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
|
|
329
|
+
if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
|
|
330
|
+
problems.push(`${at}.${k}: must be a number`);
|
|
288
331
|
}
|
|
289
|
-
if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
|
|
332
|
+
if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
|
|
333
|
+
problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
|
|
290
334
|
// agy takes the effort from the model name and rejects --effort.
|
|
291
|
-
if (def.effort !== undefined && def.cli === 'antigravity')
|
|
292
|
-
|
|
335
|
+
if (def.effort !== undefined && def.cli === 'antigravity')
|
|
336
|
+
problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
|
|
337
|
+
if (problems.length > before) {
|
|
338
|
+
broken[name] = problems.slice(before);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
293
341
|
const pair = `${def.cli}\n${def.model.trim()}`;
|
|
294
342
|
if (seen.has(pair)) {
|
|
295
343
|
problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
|
package/src/export.mjs
CHANGED
|
@@ -24,13 +24,21 @@ export class ExportError extends Error {
|
|
|
24
24
|
const historyDir = () => path.join(POLICY.home, 'history');
|
|
25
25
|
|
|
26
26
|
function readJson(p) {
|
|
27
|
-
try {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function roundsOf(chainId) {
|
|
31
35
|
const dir = path.join(historyDir(), chainId);
|
|
32
36
|
let names = [];
|
|
33
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(dir);
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
34
42
|
return names
|
|
35
43
|
.filter((n) => /^round-\d+\.json$/.test(n))
|
|
36
44
|
.sort()
|
|
@@ -51,7 +59,9 @@ function chainsOfGroup(groupId) {
|
|
|
51
59
|
const row = JSON.parse(line);
|
|
52
60
|
byJob.set(row.job_id, row.chain_id);
|
|
53
61
|
}
|
|
54
|
-
} catch {
|
|
62
|
+
} catch {
|
|
63
|
+
/* an unreadable index leaves the group unresolvable, handled below */
|
|
64
|
+
}
|
|
55
65
|
const chains = [];
|
|
56
66
|
for (const jobId of group.job_ids ?? []) {
|
|
57
67
|
const chainId = byJob.get(jobId);
|
|
@@ -80,11 +90,23 @@ function briefSection(brief) {
|
|
|
80
90
|
out.push('');
|
|
81
91
|
};
|
|
82
92
|
block('Objective', brief.objective);
|
|
83
|
-
block(
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
block(
|
|
94
|
+
'Success criteria',
|
|
95
|
+
bullets(brief.success_criteria, (c) => `- ${c}`),
|
|
96
|
+
);
|
|
97
|
+
block(
|
|
98
|
+
'Constraints',
|
|
99
|
+
bullets(brief.constraints, (c) => `- ${c}`),
|
|
100
|
+
);
|
|
101
|
+
block(
|
|
102
|
+
'Facts as given',
|
|
103
|
+
bullets(brief.facts, (f) => `- ${f}`),
|
|
104
|
+
);
|
|
86
105
|
block('Proposal', brief.proposal);
|
|
87
|
-
block(
|
|
106
|
+
block(
|
|
107
|
+
'Opposing claims',
|
|
108
|
+
bullets(brief.counterpoints, (c) => `- ${c}`),
|
|
109
|
+
);
|
|
88
110
|
for (const a of brief.artifacts ?? []) {
|
|
89
111
|
out.push(`**Material: ${a.name}** (${[a.kind, a.language, a.source].filter(Boolean).join(', ')})`, '');
|
|
90
112
|
out.push(...fence(a.excerpt, a.language));
|
|
@@ -98,15 +120,21 @@ function briefSection(brief) {
|
|
|
98
120
|
if (exposed?.entries?.length || exposed?.skipped?.length) {
|
|
99
121
|
out.push('**Files exposed to the consultant**', '');
|
|
100
122
|
for (const e of exposed.entries) {
|
|
101
|
-
const since =
|
|
123
|
+
const since =
|
|
124
|
+
e.changed_since_parent === undefined
|
|
125
|
+
? ''
|
|
126
|
+
: e.changed_since_parent
|
|
127
|
+
? ', changed since the earlier round'
|
|
128
|
+
: ', unchanged since the earlier round';
|
|
102
129
|
out.push(`- ${e.exposed_as} (${e.kind}, ${e.files} file(s), ${humanSize(e.bytes)}${since}) -- from ${e.source}`);
|
|
103
130
|
}
|
|
104
131
|
const count = (reason) => (exposed.skipped ?? []).filter((x) => x.reason === reason).length;
|
|
105
132
|
if (count('symlink')) out.push(`- ${count('symlink')} symlink(s) inside those directories were skipped, not followed`);
|
|
106
133
|
if (count('protected')) out.push(`- ${count('protected')} credential or history path(s) were left out on purpose`);
|
|
107
134
|
if (count('gone')) out.push(`- ${count('gone')} path(s) from the earlier round no longer existed and were not copied again`);
|
|
108
|
-
if (count('masked'))
|
|
109
|
-
|
|
135
|
+
if (count('masked'))
|
|
136
|
+
out.push(`- ${count('masked')} path(s) from the earlier round were masked in its record and were not copied again`);
|
|
137
|
+
if (count('replaced')) out.push("- the earlier round's change was replaced by the diff given in this round");
|
|
110
138
|
out.push('');
|
|
111
139
|
}
|
|
112
140
|
return out;
|
|
@@ -209,7 +237,9 @@ function roundSection(round, previousBrief) {
|
|
|
209
237
|
];
|
|
210
238
|
if (round.result) {
|
|
211
239
|
head.push(`- stance as declared: ${round.result.stance ?? 'not declared'}`);
|
|
212
|
-
head.push(
|
|
240
|
+
head.push(
|
|
241
|
+
`- the consultant's own confidence: ${round.result.confidence ?? 'not declared'}, evidence basis: ${round.result.evidence_basis ?? 'not declared'}`,
|
|
242
|
+
);
|
|
213
243
|
}
|
|
214
244
|
head.push('');
|
|
215
245
|
head.push('### Question', '', round.question, '');
|
|
@@ -273,5 +303,12 @@ export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
|
|
|
273
303
|
if (round.brief) previousBrief = round.brief;
|
|
274
304
|
}
|
|
275
305
|
}
|
|
276
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
markdown: `${lines
|
|
308
|
+
.join('\n')
|
|
309
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
310
|
+
.trimEnd()}\n`,
|
|
311
|
+
rounds: total,
|
|
312
|
+
chain_ids: chainIds,
|
|
313
|
+
};
|
|
277
314
|
}
|
package/src/expose-paths.mjs
CHANGED
|
@@ -57,15 +57,15 @@ function ensureRoom(totals, size) {
|
|
|
57
57
|
function checkBudget(totals) {
|
|
58
58
|
if (totals.files > POLICY.input.exposeFilesMax) {
|
|
59
59
|
throw new ExposeError(
|
|
60
|
-
`expose_paths reaches more than ${POLICY.input.exposeFilesMax} files, over the file cap; name the files or `
|
|
61
|
-
|
|
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',
|
|
62
62
|
'too_large',
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
if (totals.bytes > POLICY.input.exposeBytesMax) {
|
|
66
66
|
throw new ExposeError(
|
|
67
|
-
`expose_paths reaches more than ${humanSize(POLICY.input.exposeBytesMax)}, over the size cap; name the files `
|
|
68
|
-
|
|
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',
|
|
69
69
|
'too_large',
|
|
70
70
|
);
|
|
71
71
|
}
|
|
@@ -158,7 +158,9 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
|
158
158
|
try {
|
|
159
159
|
listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
|
|
160
160
|
ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
|
|
161
|
-
} catch {
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
162
164
|
if (listed.length === 0) return false;
|
|
163
165
|
for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
|
|
164
166
|
if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
|
|
@@ -169,21 +171,43 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
|
|
|
169
171
|
// walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
|
|
170
172
|
// listed file is checked with each directory above it for the same reason.
|
|
171
173
|
const parts = rel.split('/');
|
|
172
|
-
const guarded = parts
|
|
174
|
+
const guarded = parts
|
|
175
|
+
.slice(0, -1)
|
|
176
|
+
.map((_, i) => path.join(dir, ...parts.slice(0, i + 1)))
|
|
177
|
+
.find(isProtectedPath);
|
|
173
178
|
if (guarded) {
|
|
174
179
|
if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
|
|
175
180
|
protectedDirs.add(guarded);
|
|
176
181
|
continue;
|
|
177
182
|
}
|
|
178
|
-
if (isProtectedPath(abs)) {
|
|
183
|
+
if (isProtectedPath(abs)) {
|
|
184
|
+
skipped.push({ source: abs, reason: 'protected' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
179
187
|
let stat;
|
|
180
188
|
// Deleted in the work tree but still in the index: not part of the tree
|
|
181
189
|
// as it is now, so not part of the copy either.
|
|
182
|
-
try {
|
|
190
|
+
try {
|
|
191
|
+
stat = fs.lstatSync(abs);
|
|
192
|
+
} catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
183
195
|
// A directory here is a submodule (or a nested repository): its own history, not this one's files.
|
|
184
|
-
if (stat.isDirectory()) {
|
|
185
|
-
|
|
186
|
-
|
|
196
|
+
if (stat.isDirectory()) {
|
|
197
|
+
skipped.push({ source: abs, reason: 'submodule' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const inside = (() => {
|
|
201
|
+
try {
|
|
202
|
+
return fs.realpathSync(abs).startsWith(realDir + path.sep);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
if (stat.isSymbolicLink() || !inside) {
|
|
208
|
+
skipped.push({ source: abs, reason: 'symlink' });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
187
211
|
if (!stat.isFile()) continue;
|
|
188
212
|
onFile(abs, path.normalize(rel));
|
|
189
213
|
checkBudget(totals);
|
|
@@ -304,18 +328,41 @@ function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
|
|
|
304
328
|
const copied = [];
|
|
305
329
|
for (const c of changes) {
|
|
306
330
|
const abs = path.join(top, c.path);
|
|
307
|
-
if (isProtectedPath(abs)) {
|
|
308
|
-
|
|
331
|
+
if (isProtectedPath(abs)) {
|
|
332
|
+
manifest.skipped.push({ source: abs, reason: 'protected' });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (c.status === 'D') {
|
|
336
|
+
shown(c);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
309
339
|
let stat;
|
|
310
340
|
// When `gone` is given, the paths are an earlier round's, and a skipped
|
|
311
341
|
// one carries the name it had there.
|
|
312
342
|
const named = gone ? { exposed_as: `workspace/repo/${c.path}` } : {};
|
|
313
|
-
try {
|
|
343
|
+
try {
|
|
344
|
+
stat = fs.lstatSync(abs);
|
|
345
|
+
} catch {
|
|
346
|
+
gone?.(c);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
314
349
|
// A directory on the way that is now a link out of the repository.
|
|
315
|
-
const inside = (() => {
|
|
316
|
-
|
|
350
|
+
const inside = (() => {
|
|
351
|
+
try {
|
|
352
|
+
return fs.realpathSync(abs).startsWith(realTop + path.sep);
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
if (stat.isSymbolicLink() || !inside) {
|
|
358
|
+
manifest.skipped.push({ source: abs, reason: 'symlink', ...named });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
317
361
|
// A submodule: its commit line stays in the diff, nothing to copy.
|
|
318
|
-
if (!stat.isFile()) {
|
|
362
|
+
if (!stat.isFile()) {
|
|
363
|
+
shown(c);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
319
366
|
shown(c);
|
|
320
367
|
copied.push({ path: c.path, size: stat.size });
|
|
321
368
|
}
|
|
@@ -346,19 +393,38 @@ export function planInheritance(parentEntries, exposePaths) {
|
|
|
346
393
|
const paths = [];
|
|
347
394
|
const skipped = [];
|
|
348
395
|
const diffs = [];
|
|
349
|
-
const exists = (p) => {
|
|
396
|
+
const exists = (p) => {
|
|
397
|
+
try {
|
|
398
|
+
fs.lstatSync(p);
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
350
404
|
for (const e of parentEntries) {
|
|
351
|
-
if (String(e.source).includes('[REDACTED')) {
|
|
352
|
-
|
|
353
|
-
|
|
405
|
+
if (String(e.source).includes('[REDACTED')) {
|
|
406
|
+
skipped.push({ source: e.source, reason: 'masked', exposed_as: e.exposed_as });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (!exists(e.source)) {
|
|
410
|
+
skipped.push({ source: e.source, reason: 'gone', exposed_as: e.exposed_as });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (e.kind === 'diff' || e.kind === 'change_files') {
|
|
414
|
+
diffs.push(e);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
354
417
|
paths.push({ source: e.source, index: e.index });
|
|
355
418
|
}
|
|
356
419
|
// New paths are numbered after every name the earlier round used.
|
|
357
420
|
let next = parentEntries.reduce((n, e) => Math.max(n, e.index + 1), 0);
|
|
358
421
|
for (const p of exposePaths) if (!paths.some((x) => x.source === p)) paths.push({ source: p, index: next++ });
|
|
359
422
|
if (paths.length > POLICY.input.exposePathsMax) {
|
|
360
|
-
throw new ExposeError(
|
|
361
|
-
|
|
423
|
+
throw new ExposeError(
|
|
424
|
+
`expose_from_parent and expose_paths together name ${paths.length} entries, over the cap of ` +
|
|
425
|
+
`${POLICY.input.exposePathsMax}; name fewer new paths`,
|
|
426
|
+
'too_large',
|
|
427
|
+
);
|
|
362
428
|
}
|
|
363
429
|
return { paths, skipped, diffs };
|
|
364
430
|
}
|
|
@@ -373,21 +439,41 @@ export function reexposeChange(workdir, entry, manifest) {
|
|
|
373
439
|
for (const c of entry.changes ?? []) {
|
|
374
440
|
const m = /^(.*) \(([^()]+)\)$/.exec(c);
|
|
375
441
|
const p = m ? m[1] : c;
|
|
376
|
-
if (p.includes('[REDACTED')) {
|
|
442
|
+
if (p.includes('[REDACTED')) {
|
|
443
|
+
manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
377
446
|
// A file the change deleted that is back now is copied as it is.
|
|
378
447
|
const deleted = m?.[2] === 'deleted';
|
|
379
448
|
let back = false;
|
|
380
|
-
if (deleted)
|
|
449
|
+
if (deleted)
|
|
450
|
+
try {
|
|
451
|
+
fs.lstatSync(path.join(entry.source, p));
|
|
452
|
+
back = true;
|
|
453
|
+
} catch {
|
|
454
|
+
/* still deleted */
|
|
455
|
+
}
|
|
381
456
|
changes.push({ path: p, status: deleted ? (back ? 'restored' : 'D') : (m?.[2] ?? 'M') });
|
|
382
457
|
}
|
|
383
458
|
const before = { ...manifest.totals };
|
|
384
459
|
const shown = [];
|
|
385
|
-
const { copied, sha256 } = copyRepoFiles(
|
|
386
|
-
|
|
460
|
+
const { copied, sha256 } = copyRepoFiles(
|
|
461
|
+
workdir,
|
|
462
|
+
entry.source,
|
|
463
|
+
changes,
|
|
464
|
+
manifest,
|
|
465
|
+
(c) => shown.push(c),
|
|
466
|
+
(c) =>
|
|
467
|
+
manifest.skipped.push({ source: path.join(entry.source, c.path), reason: 'gone', exposed_as: `workspace/repo/${c.path}` }),
|
|
468
|
+
);
|
|
387
469
|
// Nothing of the change is left to show: no entry, so a follow-up with
|
|
388
470
|
// nothing else to go on is refused rather than sent with an empty copy.
|
|
389
471
|
if (shown.length === 0) {
|
|
390
|
-
try {
|
|
472
|
+
try {
|
|
473
|
+
fs.rmdirSync(path.join(workdir, 'workspace', 'repo'));
|
|
474
|
+
} catch {
|
|
475
|
+
/* holds another entry's files */
|
|
476
|
+
}
|
|
391
477
|
return false;
|
|
392
478
|
}
|
|
393
479
|
manifest.entries.push({
|
|
@@ -415,10 +501,16 @@ function git(cwd, args) {
|
|
|
415
501
|
// at another repository), and without taking the lead's index lock.
|
|
416
502
|
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('GIT_')));
|
|
417
503
|
const r = spawnSync('git', ['--literal-pathspecs', '-c', 'core.fsmonitor=', '-C', cwd, ...args], {
|
|
418
|
-
timeout: 20_000,
|
|
504
|
+
timeout: 20_000,
|
|
505
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
506
|
+
windowsHide: true,
|
|
507
|
+
env: { ...env, GIT_OPTIONAL_LOCKS: '0' },
|
|
419
508
|
});
|
|
420
509
|
if (r.error || r.status !== 0) {
|
|
421
|
-
const why =
|
|
510
|
+
const why =
|
|
511
|
+
/** @type {NodeJS.ErrnoException} */ (r.error)?.code === 'ENOENT'
|
|
512
|
+
? 'git is not on PATH'
|
|
513
|
+
: String(r.stderr ?? r.error?.message ?? '').trim();
|
|
422
514
|
throw new ExposeError(`context.expose_diff: git ${args[0]} failed in "${cwd}": ${why}`, 'not_found');
|
|
423
515
|
}
|
|
424
516
|
return r.stdout;
|
|
@@ -437,18 +529,23 @@ const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
|
|
|
437
529
|
export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars) {
|
|
438
530
|
const top = git(repo, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
|
|
439
531
|
git(top, ['rev-parse', '--verify', `${base}^{commit}`]);
|
|
440
|
-
const tokens = nulSplit(
|
|
441
|
-
'--no-textconv', base, '--'])
|
|
532
|
+
const tokens = nulSplit(
|
|
533
|
+
git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff', '--no-textconv', base, '--']),
|
|
534
|
+
);
|
|
442
535
|
const changes = [];
|
|
443
536
|
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'])))
|
|
537
|
+
for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z'])))
|
|
538
|
+
changes.push({ status: 'untracked', path: p });
|
|
445
539
|
|
|
446
540
|
const before = { ...manifest.totals };
|
|
447
541
|
const shown = [];
|
|
448
542
|
const { copied, sha256 } = copyRepoFiles(workdir, top, changes, manifest, (c) => shown.push(c));
|
|
449
543
|
if (shown.length === 0) {
|
|
450
|
-
throw new ExposeError(
|
|
451
|
-
|
|
544
|
+
throw new ExposeError(
|
|
545
|
+
`context.expose_diff: no change against ${base} to show` +
|
|
546
|
+
(changes.length ? ' (every changed file is a protected path)' : ''),
|
|
547
|
+
'not_found',
|
|
548
|
+
);
|
|
452
549
|
}
|
|
453
550
|
manifest.entries.push({
|
|
454
551
|
index: manifest.entries.reduce((n, e) => Math.max(n, e.index + 1), 0),
|
|
@@ -461,7 +558,9 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
|
|
|
461
558
|
// The copies, as paths a consultant can open; the change itself, with
|
|
462
559
|
// what happened to each path, is listed apart.
|
|
463
560
|
paths: copied,
|
|
464
|
-
changes: shown
|
|
561
|
+
changes: shown
|
|
562
|
+
.map((c) => `${c.path} (${c.status === 'untracked' ? 'untracked' : c.status === 'D' ? 'deleted' : c.status})`)
|
|
563
|
+
.sort(),
|
|
465
564
|
sha256,
|
|
466
565
|
});
|
|
467
566
|
|
|
@@ -474,18 +573,37 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
|
|
|
474
573
|
// without its old content.
|
|
475
574
|
const tracked = new Set(shown.filter((c) => c.status !== 'untracked').map((c) => c.path));
|
|
476
575
|
const full = tracked.size
|
|
477
|
-
? git(top, [
|
|
478
|
-
|
|
576
|
+
? git(top, [
|
|
577
|
+
'diff',
|
|
578
|
+
'--no-ext-diff',
|
|
579
|
+
'--no-textconv',
|
|
580
|
+
'--no-color',
|
|
581
|
+
'--src-prefix=a/',
|
|
582
|
+
'--dst-prefix=b/',
|
|
583
|
+
'--no-relative',
|
|
584
|
+
'--submodule=short',
|
|
585
|
+
'--irreversible-delete',
|
|
586
|
+
base,
|
|
587
|
+
'--',
|
|
588
|
+
]).toString('utf8')
|
|
479
589
|
: '';
|
|
480
590
|
// A section whose header names no shown path (a protected one, or one git
|
|
481
591
|
// had to quote) is dropped rather than guessed at.
|
|
482
|
-
let text = redact(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
592
|
+
let text = redact(
|
|
593
|
+
full
|
|
594
|
+
.split(/^(?=diff --git )/m)
|
|
595
|
+
.filter((sec) => {
|
|
596
|
+
const rest = sec.slice('diff --git '.length, sec.indexOf('\n'));
|
|
597
|
+
const p = rest.slice(2, 2 + (rest.length - 1) / 2 - 2);
|
|
598
|
+
return tracked.has(p) && rest === `a/${p} b/${p}`;
|
|
599
|
+
})
|
|
600
|
+
.join(''),
|
|
601
|
+
);
|
|
487
602
|
if (text.length > maxChars) {
|
|
488
|
-
text =
|
|
603
|
+
text =
|
|
604
|
+
maxChars < 500
|
|
605
|
+
? ''
|
|
606
|
+
: `${text.slice(0, maxChars - 200)}\n... [diff cut by severally: ${text.length} characters in all; every changed file is under workspace/repo/]`;
|
|
489
607
|
}
|
|
490
608
|
return text;
|
|
491
609
|
}
|