fix(cli): make the safeTarget slug space prefix-free at the dash boundary

Review's cleanup sweeps .qwen/tmp/ by qwen-review-<slug>- prefix. A slug
that itself carried '-' — natively (pr-6771 vs pr) or via the truncation
join — could extend a shorter slug, letting one target's cleanup delete a
DISTINCT target's artifacts (R8-7). The truncation branch this PR carries
newly lands deep targets inside the cap instead of dying ENAMETOOLONG,
which turned the latent collision live.

Drop '-' from the slug alphabet entirely (dashes flatten like separators)
and join the truncation digest with '_': with '-' out of every slug, the
qwen-review-<slug>- boundary is unambiguous by construction — no slug can
start with another slug plus '-'. prev-ledger side files keep their
hardcoded dashed name on both writer and reader, untouched by the slug.

Tests pin the prefix-free property (short-vs-short, short-vs-truncated)
and the fixture names follow the new slugs.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Shaojin Wen 2026-08-19 14:32:46 +08:00
parent bc3df79d47
commit 539495226d
5 changed files with 41 additions and 16 deletions

View file

@ -80,7 +80,7 @@ import {
import { runCleanup } from './cleanup.js';
const PLAN = {
diffPathAbsolute: '/abs/.qwen/tmp/qwen-review-pr-9206-diff.txt',
diffPathAbsolute: '/abs/.qwen/tmp/qwen-review-pr_9206-diff.txt',
chunks: [
{
id: 13,
@ -502,7 +502,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
writeFileSync(planPath, JSON.stringify({ prNumber: '9206' }));
const recordDir = promptRecordDir(planPath);
@ -533,7 +533,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
writeFileSync(planPath, JSON.stringify({ prNumber: '9206' }));
const recordDir = promptRecordDir(planPath);
@ -564,7 +564,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
const recordDir = promptRecordDir(planPath);
mkdirSync(recordDir, { recursive: true });
@ -601,7 +601,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
const recordDir = promptRecordDir(planPath);
mkdirSync(recordDir, { recursive: true });
@ -634,7 +634,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
writeFileSync(planPath, JSON.stringify({ prNumber: '9206' }));
const recordDir = promptRecordDir(planPath);
@ -657,7 +657,7 @@ describe('issue #9206 — Step 9 cleanup must not destroy a non-converged run
dir,
'.qwen',
'tmp',
'qwen-review-pr-9206-fetch.json',
'qwen-review-pr_9206-fetch.json',
);
writeFileSync(planPath, JSON.stringify({ prNumber: '9206' }));
const recordDir = promptRecordDir(planPath);

View file

@ -25,9 +25,9 @@ describe('PARSE_ARGS_REPORT', () => {
});
describe('tmpFile — target is a single safe component', () => {
it('keeps ordinary labels intact', () => {
it('flattens ordinary labels to the prefix-free slug', () => {
expect(tmpFile('pr-6771', 'diff.txt')).toContain(
'qwen-review-pr-6771-diff.txt',
'qwen-review-pr_6771-diff.txt',
);
expect(tmpFile('local', 'plan.json')).toContain(
'qwen-review-local-plan.json',

View file

@ -2781,7 +2781,7 @@ describe('what the reviewer caught in this change', () => {
// receipt lands there.
describe('submit receipt (producer half of the audit contract)', () => {
const receiptPath = () =>
join(dir, '.qwen', 'tmp', 'qwen-review-pr-6771-submit-receipt.json');
join(dir, '.qwen', 'tmp', 'qwen-review-pr_6771-submit-receipt.json');
const authorizedPost = (over: Record<string, unknown> = {}) =>
args({ userAuthorized: true, ...over });

View file

@ -38,6 +38,23 @@ describe('safeTarget', () => {
expect(safeTarget('///')).toBe('target');
});
it('keeps the slug space prefix-free at the dash boundary', () => {
// Review's cleanup sweeps `.qwen/tmp/` by `qwen-review-<slug>-` prefix:
// if a slug could contain `-` — natively, or via the truncation join —
// one target's slug could extend another's, and the shorter target's
// cleanup would delete the longer target's artifacts. `-` is therefore
// out of the slug alphabet entirely.
expect(safeTarget('foo-bar')).toBe('foo_bar');
const shortSlug = safeTarget('foo');
expect(safeTarget('foo-bar').startsWith(`${shortSlug}-`)).toBe(false);
const shared = Array.from({ length: 30 }, (_, i) => `level${i}`).join('/');
const deepSlug = safeTarget(`${shared}/alpha`);
expect(deepSlug).not.toContain('-');
// The short-vs-truncated direction too: a truncated slug never starts
// with `<short slug>-`, whatever the short target is.
expect(deepSlug.startsWith(`${safeTarget(shared)}-`)).toBe(false);
});
it('bounds deep targets to the one-component cap, keeping them distinct', () => {
// Deep nested paths flatten past POSIX's 255-byte filename component cap
// (ENAMETOOLONG): truncate and carry a hash of the ORIGINAL target so

View file

@ -29,22 +29,30 @@ const SAFE_TARGET_MAX_CHARS = 200;
* packages/core (agent-transcript.ts) answers the same question for
* transcript/monitor names and flattens dots instead the two stay separate
* on that deliberate difference.
*
* Dashes are flattened too, on purpose: the slugs are prefix-scanned with a
* dash boundary (`qwen-review-<slug>-` in review's cleanup), and a slug that
* itself contained `-` natively, or via the truncation join could extend
* a shorter slug, letting one target's cleanup sweep a DISTINCT target's
* artifacts. With `-` out of the slug alphabet the space is prefix-free at
* that boundary by construction.
*/
export function safeTarget(target: string): string {
let flat = target
.replace(/[^A-Za-z0-9._-]/g, '_') // separators and anything odd → underscore
.replace(/[^A-Za-z0-9._]/g, '_') // separators, dashes, anything odd → underscore
.replace(/\.\.+/g, '_'); // no run of dots survives as a traversal token
// A deep nested target flattens past the one-component byte cap
// (ENAMETOOLONG): truncate and keep uniqueness with a hash of the
// ORIGINAL target, so distinct long paths stay distinct.
// ORIGINAL target, so distinct long paths stay distinct. The digest joins
// with `_` — the one dash in a filename is the prefix-scan boundary.
if (flat.length > SAFE_TARGET_MAX_CHARS) {
const digest = createHash('sha256')
.update(target)
.digest('hex')
.slice(0, 12);
flat = `${flat.slice(0, SAFE_TARGET_MAX_CHARS - digest.length - 1)}-${digest}`;
flat = `${flat.slice(0, SAFE_TARGET_MAX_CHARS - digest.length - 1)}_${digest}`;
}
// Leading dashes too: a dash-leading slug is parsed as short flags when a
// consumer passes it as a spaced CLI argument value.
return flat.replace(/^[._-]+/, '') || 'target';
// Leading dots/underscores too: a dash-leading slug is no longer possible
// (dashes flatten), and a dot-leading component reads as a hidden file.
return flat.replace(/^[._]+/, '') || 'target';
}