mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 23:25:28 +00:00
* refactor(cli): consolidate shared helpers ahead of the legacy audit skill
Move the pieces the upcoming /audit skill needs out of command-group
ownership so no skill imports across command groups:
- the findings schema moves from commands/review/ to cli/src/utils/ as-is;
every review consumer imports it from the new home, and the stale-bundle
digest, bundle-asset list, and artifact comment track the move
- safeTarget (traversal-safe slug) and tokenizeArgs (quoted argument
splitting) lift to cli/src/utils/paths.ts and shell-args.ts, with
review's copies re-exporting/redirecting
- the two private git check-ignore copies (review test-plan, team memory)
consolidate into one fresh-by-default helper in core utils; the memo
stays caller-side so a remedy re-check observes the flip
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* fix(cli): restore review ledger machinery dropped by the consolidation
The shared-helper consolidation silently reverted three behaviors PR #9175
added to compose-review, against the PR's stated "no behavior change"
intent: the unreviewed-dimension anchor exemption (scopeUnproven /
dimensionGapsAreDepthOnly / isNonDiffDimensionGap), the LEDGER_MAX_ROUND
stamp clamp, and the bilingual budget-stop phrase splice. Restore them with
the tests that pin them; SKILL.md, ledger.ts, and deadline.ts still
document all three.
Also harden the new helper tests:
- safeTarget: the deep-path fixtures now share a flattened prefix longer
than the kept window, so a truncation-only slug (no digest) collides
instead of shipping green.
- isGitIgnored: each GIT_* scrub arm now carries a discriminating fixture
(three arms previously passed with their scrub line deleted), and every
foreign git init scrubs ambient repo-placement selectors.
* fix(core): scrub git config-injection channels from the ignore probe
Ambient GIT_CONFIG_COUNT (inline KEY/VALUE injection) and
GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM (config-file redirects) can aim
core.excludesFile at a foreign rules file and flip the probe's verdict
for the -C worktree — the same leak class the existing selector scrubs
close. Measured on the pristine probe: both channels turn a
not-ignored path into an ignored one.
Also pin the guards the review found unpinned: the two config
channels, the `--` separator for dash-leading paths, the timeoutMs
wiring, safeTarget's hash-of-original-target property, and
tree-existence of the lifted review helpers.
* fix(core): scrub pathspec-magic env channels from the ignore probe
* fix(core): drop the whole GIT_* env family from the ignore probe
Two more leak channels surfaced on the probe's per-variable scrub list:
GIT_ICASE_PATHSPECS (the fourth pathspec modifier — ambient, makes
check-ignore reject every pathspec with exit 128, which the catch reads
as not-ignored) and GIT_CONFIG_PARAMETERS (the inline -c channel git
itself uses to propagate config to children — ambient, can aim
core.excludesFile at a foreign rules file). Measured through the real
function: the first flips a genuinely ignored path to not-ignored, the
second flips a not-ignored path to ignored.
Since the channel list grew by one leak per review round, drop the
whole GIT_* family instead of enumerating, and close the system config
tier explicitly (GIT_CONFIG_NOSYSTEM=1) so host policy in
/etc/gitconfig can no longer answer for the -C worktree — that ambient
dependency also made the config-redirect arm red on any host whose
system config matches the probe path. Pin the icase member, the
PARAMETERS channel, the default 5 s deadline (previously unpinned), and
add a lower timing bound to the caller-deadline arm so it cannot pass
vacuously when the shim is not executable.
* 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>
* revert(cli): restore safeTarget byte-identical to the pre-lift behavior
Reverts the slug alphabet change (539495226d) and drops the deep-target
truncation branch, per review round findings R12-1 and R8-7:
- R12-1: flattening dashes renamed only one side of review's two-sided
artifact-naming contract — bundled-skill templates, composed names,
prev-ledger and brief/report producers hardcode the dash spelling, so
the bypass-audit tripwire would silently skip and the cleanup sweep
would leak ~15 dash-form artifacts per review.
- R8-7: the prefix-sweep hazard only exists because of the truncation
branch this lift carried; main's safeTarget has no truncation, so a
behavior-preserving lift must not add it. Deep-target support belongs
in a follow-up paired with the sweep-side structural fix it needs.
safeTarget is now byte-identical to the pre-lift implementation (moved,
not modified); tests pin the dash spelling and the leading-strip rule as
they behave on main. Full review suite green (5022).
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
---------
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// The review source digest is computed twice: once by the build, which stamps
|
|
// it beside the bundle, and once by the review commands (`parse-args`, and
|
|
// `drive`, which agents can reach directly without `parse-args` ever running
|
|
// first), which re-derive it from the tree and compare.
|
|
// A rule stated twice is a rule that will be true in one
|
|
// place, and the two cannot share code — the build script runs before the
|
|
// package it would import has been built. So this is the test that keeps them
|
|
// equal, and it lives here because a package test is not allowed to reach into
|
|
// `scripts/`. It runs under `npm run test:scripts` (part of `npm run
|
|
// test:ci`), not `npm test` — a digest change verified only against the
|
|
// package suite never reaches it.
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
rmSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { extname, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
BUNDLED_SKILL_TEST_FILE_RE,
|
|
copyBundleAssets,
|
|
reviewSourceDigestForBuild,
|
|
} from '../copy_bundle_assets.js';
|
|
import { isAllowedDistEntry } from '../create-standalone-package.js';
|
|
import {
|
|
DIGESTED_EXTENSIONS,
|
|
DIGEST_FILE,
|
|
NOT_BUNDLED_SKILL_FILE,
|
|
reviewSourceRoots,
|
|
reviewSourcesDigest,
|
|
} from '../../packages/cli/src/commands/review/lib/stale-bundle.js';
|
|
|
|
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
|
|
|
/**
|
|
* The name the build actually writes, taken by running it — not by matching a
|
|
* pattern against its source, which is how the first version of this broke:
|
|
* the literal moved into a `stampPath` variable and the regex quietly returned
|
|
* `undefined`, so the assertion compared against nothing and the test went red
|
|
* only when something else happened to run it.
|
|
*/
|
|
function stampNameWrittenByBuild(): string | undefined {
|
|
const root = mkdtempSync(join(tmpdir(), 'stamp-name-'));
|
|
// The copier logs or warns about every asset class it meets; without a
|
|
// stub, this case adds that noise to the suite's output on every run —
|
|
// `package-assets.test.js` stubs for exactly this reason.
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
try {
|
|
const cli = join(root, 'packages', 'cli', 'src', 'commands');
|
|
mkdirSync(join(cli, 'review'), { recursive: true });
|
|
writeFileSync(join(cli, 'review', 'drive.ts'), 'export const a = 1;');
|
|
mkdirSync(join(root, 'dist'), { recursive: true });
|
|
writeFileSync(join(root, 'dist', 'cli.js'), 'bundle');
|
|
const before = new Date(Date.now() - 60_000);
|
|
utimesSync(join(cli, 'review', 'drive.ts'), before, before);
|
|
copyBundleAssets({ root });
|
|
return readdirSync(join(root, 'dist')).find((f) => f.endsWith('.sha256'));
|
|
} finally {
|
|
log.mockRestore();
|
|
warn.mockRestore();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe('the build stamp and the staleness check agree', () => {
|
|
it('hashes this repository to the same digest', () => {
|
|
const fromCheck = reviewSourcesDigest(
|
|
repoRoot,
|
|
reviewSourceRoots(repoRoot),
|
|
);
|
|
expect(fromCheck).toBeDefined();
|
|
expect(reviewSourceDigestForBuild(repoRoot).digest).toBe(fromCheck);
|
|
});
|
|
|
|
it('writes and reads the same filename', () => {
|
|
// The build stamps a literal and the check reads `DIGEST_FILE`. A
|
|
// one-sided rename leaves the read throwing, the comparison unmeasured,
|
|
// and the warning silently never firing again — with the digest parity
|
|
// above still green, because it never touches the name.
|
|
expect(stampNameWrittenByBuild()).toBe(DIGEST_FILE);
|
|
});
|
|
|
|
it('stamps a file the standalone packager will accept', () => {
|
|
// `createStandalonePackage` fails on any top-level dist entry outside its
|
|
// allowlist, and no PR-time job runs it — so without this, dropping the
|
|
// entry or renaming the stamp on one side is discovered when a release is
|
|
// cut, on all five targets at once.
|
|
expect(isAllowedDistEntry(DIGEST_FILE)).toBe(true);
|
|
});
|
|
|
|
it('agrees on a synthetic tree too, including a file-shaped root', () => {
|
|
// The repo case cannot vary; this one can. A root that is a single file is
|
|
// how `review.ts` — where every subcommand is registered — is covered, and
|
|
// it is the part of the walk most likely to drift between two copies.
|
|
const root = mkdtempSync(join(tmpdir(), 'digest-parity-'));
|
|
try {
|
|
const cli = join(root, 'packages', 'cli', 'src', 'commands');
|
|
mkdirSync(join(cli, 'review', 'lib'), { recursive: true });
|
|
mkdirSync(
|
|
join(root, 'packages', 'core', 'src', 'skills', 'bundled', 'review'),
|
|
{ recursive: true },
|
|
);
|
|
const skillDir = join(
|
|
root,
|
|
'packages',
|
|
'core',
|
|
'src',
|
|
'skills',
|
|
'bundled',
|
|
'review',
|
|
);
|
|
writeFileSync(join(cli, 'review.ts'), 'registers everything');
|
|
// The lease is the other file-shaped root, and the only one outside
|
|
// `commands/`. Materialized here so its pin is local: the repo-tree
|
|
// case holds it only through the real file, and would silently drop
|
|
// the pin the day that file moved.
|
|
const services = join(root, 'packages', 'cli', 'src', 'services');
|
|
mkdirSync(services, { recursive: true });
|
|
writeFileSync(
|
|
join(services, 'review-worktree-lease.ts'),
|
|
'leases the worktree',
|
|
);
|
|
// The lifted review helpers are file-shaped roots outside `commands/`,
|
|
// same as the lease. Materialized here so their pins are local: the
|
|
// repo-tree case holds them only through the real files, and would
|
|
// silently drop the pins the day those files moved.
|
|
const utils = join(root, 'packages', 'cli', 'src', 'utils');
|
|
mkdirSync(utils, { recursive: true });
|
|
writeFileSync(join(utils, 'findings.ts'), 'validates the findings');
|
|
writeFileSync(join(utils, 'shell-args.ts'), 'tokenizes the args');
|
|
writeFileSync(join(utils, 'paths.ts'), 'flattens the slug');
|
|
writeFileSync(join(cli, 'review', 'drive.ts'), 'drives');
|
|
writeFileSync(join(cli, 'review', 'lib', 'ledger.ts'), 'ledgers');
|
|
// One production file per admitted code extension: dropping a member
|
|
// from one implementation's allowlist used to keep every suite green,
|
|
// because `.ts` was the only extension this tree exercised.
|
|
writeFileSync(join(cli, 'review', 'view.jsx'), 'export const v = 1;');
|
|
writeFileSync(join(cli, 'review', 'mod.mts'), 'export const m = 1;');
|
|
writeFileSync(join(cli, 'review', 'mod.cts'), 'export const c = 1;');
|
|
writeFileSync(join(cli, 'review', 'util.js'), 'export const u = 1;');
|
|
writeFileSync(join(cli, 'review', 'util.mjs'), 'export const w = 1;');
|
|
writeFileSync(join(cli, 'review', 'data.json'), '{}');
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '# skill');
|
|
// A fixture directory under the SKILL root is still digested: the
|
|
// directory exclusions are a code-root concern (`kind !== 'code'`),
|
|
// and nothing pinned the qualifier — it survived removal on both sides.
|
|
mkdirSync(join(skillDir, '__fixtures__'), { recursive: true });
|
|
writeFileSync(join(skillDir, '__fixtures__', 'example.md'), '# fixture');
|
|
// DESIGN.md is the copier's deliberate skip, so it must move neither
|
|
// side of the digest — pinned by the count below staying at 15.
|
|
writeFileSync(join(skillDir, 'DESIGN.md'), '# design');
|
|
|
|
expect(reviewSourceDigestForBuild(root).digest).toBe(
|
|
reviewSourcesDigest(root, reviewSourceRoots(root)),
|
|
);
|
|
expect(reviewSourceDigestForBuild(root).count).toBe(15);
|
|
|
|
// ...and neither a test file, nor a spec, nor a fixture moves either.
|
|
writeFileSync(join(cli, 'review', 'drive.test.ts'), 'a test');
|
|
writeFileSync(join(cli, 'review', 'drive.spec.tsx'), 'a spec');
|
|
// The `[cm]?` group, pinned on both sides: the two files above would
|
|
// still agree if one side lost the `c` or the `m`, so a one-sided edit
|
|
// there used to pass both parity cases.
|
|
writeFileSync(join(cli, 'review', 'drive.test.mts'), 'an mts test');
|
|
writeFileSync(join(cli, 'review', 'drive.spec.cts'), 'a cts spec');
|
|
// The `j` alternative of `[jt]sx?`: every fixture above resolves
|
|
// through the `t` branch, and the real roots hold no `.js` tests.
|
|
writeFileSync(join(cli, 'review', 'drive.test.js'), 'a js test');
|
|
// The `(?:d\.)?` group: without it, even a TEST declaration file
|
|
// escapes exclusion, because `.ts` admits it and `test.d.ts` breaks
|
|
// the plain match.
|
|
writeFileSync(join(cli, 'review', 'drive.test.d.ts'), 'export {};');
|
|
// The NOT_BUNDLED_FILE entry and a snapshot dir, pinned on both sides.
|
|
// The snapshot half exists only here: no `__snapshots__` directory
|
|
// lives in the repo tree, so this synthetic one is the only pin. The
|
|
// NOT_BUNDLED_FILE half is different — `lib/test-utils.ts` is a real
|
|
// file in the tree, so the repo-level parity case above pins that rule
|
|
// too; deleting the real file would silently drop that second pin and
|
|
// leave only this synthetic one.
|
|
writeFileSync(join(cli, 'review', 'lib', 'test-utils.ts'), 'test help');
|
|
writeFileSync(join(cli, 'review', '.DS_Store'), 'finder droppings');
|
|
mkdirSync(join(cli, 'review', '__snapshots__'), { recursive: true });
|
|
// A digested extension and a non-test name: only the directory rule
|
|
// can keep this out, where the `.snap` it replaced was already
|
|
// rejected by the extension allowlist and pinned nothing.
|
|
writeFileSync(
|
|
join(cli, 'review', '__snapshots__', 'snapshot-helper.ts'),
|
|
'exports[`a`] = `b`;',
|
|
);
|
|
mkdirSync(join(cli, 'review', '__fixtures__'), { recursive: true });
|
|
writeFileSync(
|
|
join(cli, 'review', '__fixtures__', 'responder.mjs'),
|
|
'export const a = 1;',
|
|
);
|
|
// Stray files no build can fold into the bundle, pinned on both sides:
|
|
// the allowlist is what ends this class, and a one-sided widening would
|
|
// accuse a byte-for-byte correct bundle on one side of the boundary.
|
|
writeFileSync(join(cli, 'review', 'drive.ts.orig'), 'rebase droppings');
|
|
writeFileSync(join(cli, 'review', 'notes.md'), 'scratch');
|
|
writeFileSync(join(skillDir, 'SKILL.md.orig'), 'droppings');
|
|
writeFileSync(join(skillDir, 'scratch.txt'), 'x');
|
|
expect(reviewSourceDigestForBuild(root).digest).toBe(
|
|
reviewSourcesDigest(root, reviewSourceRoots(root)),
|
|
);
|
|
expect(reviewSourceDigestForBuild(root).count).toBe(15);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// chmod is the only lever this case has: on Windows it is a no-op, and a
|
|
// root user reads through it, so the branch under test is unreachable
|
|
// there. The case skips rather than measuring a readable tree and failing
|
|
// red against the throw-and-nothing-measured assertions.
|
|
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
|
'neither side measures a tree whose subdirectory cannot be listed',
|
|
() => {
|
|
// The unreadable-DIRECTORY case, on both sides of the boundary at once:
|
|
// the build refuses and the runtime check reports unmeasured, so neither
|
|
// accuses a bundle that is merely unreadable. The tree holds two files,
|
|
// because with exactly one, hashing the survivors and measuring nothing
|
|
// are the same answer and a one-sided fix would keep both sides equal.
|
|
const root = mkdtempSync(join(tmpdir(), 'digest-unreadable-'));
|
|
try {
|
|
const cli = join(root, 'packages', 'cli', 'src', 'commands');
|
|
const lib = join(cli, 'review', 'lib');
|
|
mkdirSync(lib, { recursive: true });
|
|
mkdirSync(
|
|
join(root, 'packages', 'core', 'src', 'skills', 'bundled', 'review'),
|
|
{ recursive: true },
|
|
);
|
|
writeFileSync(join(cli, 'review', 'drive.ts'), 'drives');
|
|
writeFileSync(join(lib, 'ledger.ts'), 'ledgers');
|
|
chmodSync(lib, 0o000);
|
|
try {
|
|
expect(() => reviewSourceDigestForBuild(root)).toThrow();
|
|
expect(
|
|
reviewSourcesDigest(root, reviewSourceRoots(root)),
|
|
).toBeUndefined();
|
|
} finally {
|
|
chmodSync(lib, 0o755);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('digests the review helpers lifted into utils/', () => {
|
|
// findings.ts and its two helpers were lifted out of commands/review/,
|
|
// which the digest covered. Root lists that lost them would keep both
|
|
// copies equal while a skipped rebuild silently runs the bundle's old
|
|
// validator — name the files so the omission fails here.
|
|
const roots = reviewSourceRoots(repoRoot).map((r) => r.path);
|
|
const utils = join(repoRoot, 'packages', 'cli', 'src', 'utils');
|
|
expect(roots).toContain(join(utils, 'findings.ts'));
|
|
expect(roots).toContain(join(utils, 'shell-args.ts'));
|
|
expect(roots).toContain(join(utils, 'paths.ts'));
|
|
// List membership is not tree existence: a moved file keeps its
|
|
// root listed — both digest copies stay equal — while absentRoots
|
|
// darkens every review's staleness check.
|
|
expect(existsSync(join(utils, 'findings.ts'))).toBe(true);
|
|
expect(existsSync(join(utils, 'shell-args.ts'))).toBe(true);
|
|
expect(existsSync(join(utils, 'paths.ts'))).toBe(true);
|
|
});
|
|
|
|
it('the skill allowlist covers everything the copier would ship', () => {
|
|
// The copier copies all of a bundled skill but test files, DESIGN.md and
|
|
// `.DS_Store`; the digest's skill root admits its extension allowlist. A
|
|
// file the copier ships but the digest cannot see is a silent false
|
|
// negative — the direction this whole check exists not to produce. The
|
|
// skill ships a single markdown file today, so this holds; the day it
|
|
// grows a script the allowlist must grow with it, and the failure belongs
|
|
// here, not in a review that quietly stops noticing.
|
|
const skillDir = join(
|
|
repoRoot,
|
|
'packages',
|
|
'core',
|
|
'src',
|
|
'skills',
|
|
'bundled',
|
|
'review',
|
|
);
|
|
const shipped: string[] = [];
|
|
const admitted: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, e.name);
|
|
if (e.isDirectory()) walk(full);
|
|
else if (e.isFile()) {
|
|
if (
|
|
e.name !== '.DS_Store' &&
|
|
e.name !== 'DESIGN.md' &&
|
|
!BUNDLED_SKILL_TEST_FILE_RE.test(e.name)
|
|
)
|
|
shipped.push(full);
|
|
if (
|
|
DIGESTED_EXTENSIONS.skill.has(extname(e.name)) &&
|
|
!NOT_BUNDLED_SKILL_FILE.has(e.name)
|
|
)
|
|
admitted.push(full);
|
|
}
|
|
}
|
|
};
|
|
walk(skillDir);
|
|
expect(shipped.length).toBeGreaterThan(0);
|
|
for (const f of shipped) {
|
|
expect(DIGESTED_EXTENSIONS.skill.has(extname(f))).toBe(true);
|
|
}
|
|
// …and the reverse: nothing the digest folds in is a file the copier
|
|
// skips. Today `skill = {.md}` makes the two lists equal; the day a code
|
|
// extension joins the skill set, `SKILL.test.ts` becomes digested while
|
|
// the copier does not ship it, and an edit to it would move the digest
|
|
// without being able to change a byte of the bundle.
|
|
expect(admitted).toEqual(shipped);
|
|
});
|
|
});
|