Skip to content

Commit ccc852b

Browse files
authored
fix(agent-eval): drop harvested queries whose project cannot be read (#532)
* fix(agent-eval): drop harvested queries whose project cannot be read A code-mode call that passed projectPath as a variable or expression was credited to the session's starting directory, so a query about another project could be kept under a public repository. A relative projectPath was resolved against the harvester's directory instead of the session's. Such calls are now resolved against the session directory or dropped. Also: --public accepts a remote URL or a name ending in .git, and the script's entry check works through symlinks and paths with spaces. * test(agent-eval): run the harvester end to end on an unreadable projectPath
1 parent 950635c commit ccc852b

2 files changed

Lines changed: 68 additions & 11 deletions

File tree

‎__tests__/query-replay.test.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { readdirSync, readFileSync } from 'node:fs';
1+
import { spawnSync, execFileSync } from 'node:child_process';
2+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
24
import { join, resolve } from 'node:path';
35
import { describe, expect, it } from 'vitest';
46
// @ts-expect-error untyped ESM script
5-
import { callsInLine, repoSlug } from '../scripts/agent-eval/harvest-queries.mjs';
7+
import { callDirectory, callsInLine, repoSlug } from '../scripts/agent-eval/harvest-queries.mjs';
68
// @ts-expect-error untyped ESM script
79
import { compareRow } from '../scripts/agent-eval/replay-queries.mjs';
810

@@ -60,6 +62,39 @@ describe('query harvest', () => {
6062
.toEqual([{ query: 'Q', projectPath: undefined, maxFiles: 3 }]);
6163
});
6264

65+
it('never credits a call with an unreadable or relative project to the wrong repository', () => {
66+
const call = (input: string) => callsInLine({ payload: { type: 'custom_tool_call', input } })[0];
67+
const variable = call('const p = "/private"; codegraph_explore({query:"Q", projectPath: p})');
68+
expect(variable).toEqual({ query: 'Q', projectPath: null });
69+
expect(callDirectory(variable, '/public')).toBeNull();
70+
expect(callDirectory(call('codegraph_explore({query:"Q", projectPath: join(a, "b")})'), '/public')).toBeNull();
71+
expect(callDirectory(call('codegraph_explore({query:"Q", projectPath: `${root}/x`})'), '/public')).toBeNull();
72+
expect(callDirectory({ query: 'Q', projectPath: 'sub/dir' }, '/session')).toBe(resolve('/session', 'sub/dir'));
73+
expect(callDirectory({ query: 'Q', projectPath: 'sub/dir' }, undefined)).toBeNull();
74+
expect(callDirectory({ query: 'Q', projectPath: 7 }, '/public')).toBeNull();
75+
expect(callDirectory({ query: 'Q', projectPath: undefined }, '/session')).toBe('/session');
76+
expect(callDirectory({ query: 'Q', projectPath: '/abs' }, '/session')).toBe('/abs');
77+
});
78+
it('harvests only calls whose project it can read', () => {
79+
const root = mkdtempSync(join(tmpdir(), 'harvest-'));
80+
try {
81+
const repo = join(root, 'repo');
82+
execFileSync('git', ['init', '-q', repo]);
83+
execFileSync('git', ['-C', repo, 'remote', 'add', 'origin', 'https://github.com/pub/repo.git']);
84+
const explore = (input: string) => JSON.stringify({ cwd: repo, payload: { type: 'custom_tool_call', input } });
85+
writeFileSync(join(root, 's.jsonl'), [
86+
explore('codegraph_explore({query:"kept"})'),
87+
explore('codegraph_explore({query:"unreadable", projectPath: p})'),
88+
].join('\n'));
89+
const script = resolve(import.meta.dirname, '../scripts/agent-eval/harvest-queries.mjs');
90+
const run = spawnSync(process.execPath, [script, '--public', 'https://github.com/pub/repo', join(root, 's.jsonl')], { encoding: 'utf8' });
91+
expect(run.status).toBe(0);
92+
expect(JSON.parse(run.stdout).repos['pub/repo'].tasks.map((t: { prompt: string }) => t.prompt)).toEqual(['kept']);
93+
expect(run.stderr).toContain('kept 1, duplicates 0, dropped 1');
94+
} finally {
95+
rmSync(root, { recursive: true, force: true });
96+
}
97+
});
6398
it('maps GitHub remotes to owner/repo', () => {
6499
expect(repoSlug('git@github.com:Owner/Repo.git\n')).toBe('owner/repo');
65100
expect(repoSlug('https://github.com/owner/repo')).toBe('owner/repo');

‎scripts/agent-eval/harvest-queries.mjs‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
import { execFileSync } from 'node:child_process';
1717
import { createReadStream, readdirSync, statSync } from 'node:fs';
1818
import { createInterface } from 'node:readline';
19-
import { join } from 'node:path';
19+
import { isAbsolute, join, resolve } from 'node:path';
20+
import { fileURLToPath } from 'node:url';
2021
import { createHash } from 'node:crypto';
2122

2223
/** Explore calls in one transcript line: [{ query, projectPath?, maxFiles? }]. */
@@ -51,8 +52,9 @@ const ESCAPES = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0'
5152
/**
5253
* The top-level string and number fields of the JavaScript object literal that
5354
* opens at `src[open]` ('{'). Code-mode calls are JavaScript, not JSON: keys may
54-
* be bare, strings may use any quote and may contain braces. Values that are
55-
* not plain literals (expressions, template strings with `${`) are skipped.
55+
* be bare, strings may use any quote and may contain braces. A field whose
56+
* value is not a plain literal (a variable, an expression, a template string
57+
* with `${`) is null, so a caller can tell it was present but unreadable.
5658
*/
5759
export function literalFields(src, open) {
5860
const fields = {};
@@ -68,9 +70,10 @@ export function literalFields(src, open) {
6870
}
6971
i = j;
7072
if (depth === 1 && expectKey) { key = s; expectKey = false; }
71-
else if (depth === 1 && expectValue && key !== undefined && literal) fields[key] = s;
73+
else if (depth === 1 && expectValue && key !== undefined) fields[key] = literal ? s : null;
7274
expectValue = false;
7375
} else if (ch === '{' || ch === '[' || ch === '(') {
76+
if (depth === 1 && expectValue && key !== undefined) fields[key] = null;
7477
depth++;
7578
if (depth === 1) expectKey = true;
7679
expectValue = false;
@@ -84,13 +87,27 @@ export function literalFields(src, open) {
8487
const word = /^[A-Za-z_$0-9.]+/.exec(src.slice(i))[0];
8588
i += word.length - 1;
8689
if (expectKey) { key = word; expectKey = false; }
87-
else if (expectValue && key !== undefined && /^\d+$/.test(word)) fields[key] = Number(word);
90+
else if (expectValue && key !== undefined) fields[key] = /^\d+$/.test(word) ? Number(word) : null;
8891
expectValue = false;
8992
}
9093
}
9194
return fields;
9295
}
9396

97+
/**
98+
* The directory a call asked about: its projectPath, resolved against the
99+
* session's working directory when relative, or that directory when the call
100+
* named none. Null when it cannot be known (a projectPath that is not a plain
101+
* string, or a relative one with no session directory): such a call is
102+
* dropped, never credited to the directory the session started in.
103+
*/
104+
export function callDirectory(call, cwd) {
105+
if (call.projectPath === undefined) return cwd ?? null;
106+
if (typeof call.projectPath !== 'string' || call.projectPath === '') return null;
107+
if (isAbsolute(call.projectPath)) return call.projectPath;
108+
return cwd ? resolve(cwd, call.projectPath) : null;
109+
}
110+
94111
/** `owner/repo` from a remote on github.com itself (HTTPS, SSH URL or scp form), or null. */
95112
export function repoSlug(url) {
96113
const text = url.trim();
@@ -115,7 +132,10 @@ async function main(argv) {
115132
const inputs = [];
116133
let since = '';
117134
for (let i = 0; i < argv.length; i++) {
118-
if (argv[i] === '--public') pub.add(argv[++i].toLowerCase());
135+
if (argv[i] === '--public') {
136+
const name = argv[++i];
137+
pub.add(repoSlug(name) ?? name.toLowerCase().replace(/\.git$/, ''));
138+
}
119139
else if (argv[i] === '--since') since = argv[++i];
120140
else inputs.push(argv[i]);
121141
}
@@ -148,7 +168,7 @@ async function main(argv) {
148168
cwd = entry.cwd ?? entry.payload?.cwd ?? cwd;
149169
if (since && (entry.timestamp ?? '') < since) continue;
150170
for (const call of callsInLine(entry)) {
151-
const dir = call.projectPath ?? cwd;
171+
const dir = callDirectory(call, cwd);
152172
const slug = dir ? slugFor(dir) : null;
153173
if (!slug || !pub.has(slug)) { dropped++; continue; }
154174
const prompt = call.query.trim().replace(/\s+/g, ' ');
@@ -166,8 +186,10 @@ async function main(argv) {
166186
}
167187
}
168188
console.log(JSON.stringify({ repos }, null, 2));
169-
console.error(`kept ${kept}, duplicates ${duplicate}, dropped ${dropped} (not on a --public repository)`);
189+
console.error(`kept ${kept}, duplicates ${duplicate}, dropped ${dropped} (project unknown or not on a --public repository)`);
170190
return 0;
171191
}
172192

173-
if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2)).then((code) => process.exit(code));
193+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
194+
main(process.argv.slice(2)).then((code) => process.exit(code));
195+
}

0 commit comments

Comments
 (0)