fix(bench): isolate reproducible benchmark inputs

This commit is contained in:
KumamuKuma
2026-07-17 02:38:20 +08:00
parent 876faa9b97
commit c4bdcf26b9
2 changed files with 212 additions and 35 deletions
@@ -7,6 +7,7 @@ import {
rmSync,
chmodSync,
existsSync,
symlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
@@ -60,12 +61,13 @@ const _runScriptOutputDirs = [];
* { status, stdout, stderr, output } where `output` is the parsed JSON
* written by the script (or null on failure).
*/
function runScript(projectRoot) {
function runScript(projectRoot, extraArgs = [], envOverrides = {}) {
const outputDir = mkdtempSync(join(tmpdir(), 'ua-scan-out-'));
_runScriptOutputDirs.push(outputDir);
const outputPath = join(outputDir, 'scan-output.json');
const result = spawnSync('node', [SCRIPT, projectRoot, outputPath], {
const result = spawnSync('node', [SCRIPT, projectRoot, outputPath, ...extraArgs], {
encoding: 'utf-8',
env: { ...process.env, ...envOverrides },
});
let output = null;
try {
@@ -517,6 +519,19 @@ describe('scan-project.mjs — data-dir resolution (.ua vs legacy)', () => {
expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
expect(r.output.filteredByIgnore).toBe(2);
});
it('can exclude persistent analysis data for isolated benchmark scans', () => {
projectRoot = setupTree({
'.ua/knowledge-graph.json': '{ "nodes": [] }\n',
'.understand-anything/meta.json': '{ "version": 1 }\n',
'src/index.ts': 'export const x = 1;\n',
});
const r = runScript(projectRoot, ['--exclude-analysis-data']);
expect(r.status).toBe(0);
expect(r.output.files.map(file => file.path)).toEqual(['src/index.ts']);
});
});
describe('scan-project.mjs — special-file recognition', () => {
@@ -567,6 +582,105 @@ describe('scan-project.mjs — determinism', () => {
expect(r2.status).toBe(0);
expect(JSON.stringify(r1.output)).toBe(JSON.stringify(r2.output));
});
it('keeps Unicode and space-containing paths stable across process locales', () => {
projectRoot = setupTree({
'!bang.ts': 'export const bang = true;\n',
'_under.ts': 'export const under = true;\n',
'space dir/å.ts': 'export const nested = true;\n',
'ä.ts': 'export const umlaut = true;\n',
'中.ts': 'export const cjk = true;\n',
});
const cLocale = runScript(projectRoot, [], { LANG: 'C', LC_ALL: 'C' });
const swedishLocale = runScript(projectRoot, [], {
LANG: 'sv_SE.UTF-8',
LC_ALL: 'sv_SE.UTF-8',
});
const expectedPaths = [
'!bang.ts',
'_under.ts',
'space dir/å.ts',
'ä.ts',
'中.ts',
];
expect(cLocale.status).toBe(0);
expect(swedishLocale.status).toBe(0);
expect(cLocale.output.files.map(file => file.path)).toEqual(expectedPaths);
expect(swedishLocale.output.files.map(file => file.path)).toEqual(expectedPaths);
expect(JSON.stringify(cLocale.output)).toBe(JSON.stringify(swedishLocale.output));
});
it('emits a top-level lowercase SHA-256 content fingerprint as contentDigest', () => {
projectRoot = setupTree({
'src/value.ts': 'export const value = 1;\n',
});
const result = runScript(projectRoot);
expect(result.status).toBe(0);
expect(result.output.contentDigest).toMatch(/^[0-9a-f]{64}$/);
});
it('changes the content fingerprint when bytes change but metadata does not', () => {
projectRoot = setupTree({
'src/value.ts': 'export const value = "aa";\n',
});
const before = runScript(projectRoot);
writeFileSync(
join(projectRoot, 'src/value.ts'),
'export const value = "bb";\n',
'utf-8',
);
const after = runScript(projectRoot);
expect(before.status).toBe(0);
expect(after.status).toBe(0);
expect(byPath(before.output, 'src/value.ts').sizeLines).toBe(1);
expect(byPath(after.output, 'src/value.ts').sizeLines).toBe(1);
expect(after.output.files).toEqual(before.output.files);
expect(after.output.contentDigest).not.toBe(before.output.contentDigest);
});
it('omits a Git-tracked outbound symlink and never fingerprints its target bytes', () => {
projectRoot = setupTree({
'src/inside.ts': 'export const inside = true;\n',
});
const externalRoot = mkdtempSync(join(tmpdir(), 'ua-scan-external-'));
const externalFile = join(externalRoot, 'secret.ts');
try {
writeFileSync(externalFile, 'export const secret = "first";\n', 'utf-8');
try {
symlinkSync(externalFile, join(projectRoot, 'outbound-link.ts'), 'file');
} catch (error) {
if (process.platform === 'win32') return;
throw error;
}
const add = spawnSync('git', ['add', '--', 'outbound-link.ts'], {
cwd: projectRoot,
encoding: 'utf-8',
});
expect(add.status).toBe(0);
const before = runScript(projectRoot);
writeFileSync(externalFile, 'export const secret = "second";\n', 'utf-8');
const after = runScript(projectRoot);
expect(before.status).toBe(0);
expect(after.status).toBe(0);
expect(byPath(before.output, 'src/inside.ts')).toBeDefined();
expect(byPath(before.output, 'outbound-link.ts')).toBeUndefined();
expect(before.stderr).toMatch(
/Warning: scan-project: outbound-link\.ts — symbolic link skipped — file skipped from output/,
);
expect(after.output.contentDigest).toBe(before.output.contentDigest);
} finally {
rmSync(externalRoot, { recursive: true, force: true });
}
});
});
describe('scan-project.mjs — empty repo', () => {
@@ -767,6 +881,7 @@ describe('scan-project.mjs — output schema invariants', () => {
expect(typeof out.totalFiles).toBe('number');
expect(out.totalFiles).toBe(out.files.length);
expect(typeof out.filteredByIgnore).toBe('number');
expect(out.contentDigest).toMatch(/^[0-9a-f]{64}$/);
expect(['small', 'moderate', 'large', 'very-large']).toContain(
out.estimatedComplexity,
);
@@ -776,6 +891,9 @@ describe('scan-project.mjs — output schema invariants', () => {
expect(typeof out.stats.byLanguage).toBe('object');
// Per-file shape
for (const f of out.files) {
expect(Object.keys(f).sort()).toEqual([
'fileCategory', 'language', 'path', 'sizeLines',
]);
expect(typeof f.path).toBe('string');
expect(typeof f.language).toBe('string');
expect(typeof f.sizeLines).toBe('number');
@@ -785,17 +903,17 @@ describe('scan-project.mjs — output schema invariants', () => {
}
});
it('files[] is sorted by path.localeCompare', () => {
it('files[] is sorted by a locale-independent code-unit order', () => {
projectRoot = setupTree({
'zzz.ts': '\n',
'aaa.ts': '\n',
'mmm.ts': '\n',
'subdir/file.ts': '\n',
'!bang.ts': '\n',
'0.ts': '\n',
'_under.ts': '\n',
'a.ts': '\n',
'ä.ts': '\n',
});
const r = runScript(projectRoot);
expect(r.status).toBe(0);
const paths = r.output.files.map(f => f.path);
const sortedPaths = [...paths].sort((a, b) => a.localeCompare(b));
expect(paths).toEqual(sortedPaths);
expect(paths).toEqual(['!bang.ts', '0.ts', '_under.ts', 'a.ts', 'ä.ts']);
});
});
@@ -26,13 +26,14 @@
* - Complexity estimation (project-scanner.md Step 7 thresholds)
*
* Usage:
* node scan-project.mjs <projectRoot> <outputPath>
* node scan-project.mjs <projectRoot> <outputPath> [--exclude-analysis-data]
*
* Output JSON (subset of what project-scanner.md Phase 1 expects — the LLM
* agent merges this with Step A's narrative fields and Step C's importMap to
* produce the final scan-result.json):
* {
* "scriptCompleted": true,
* "contentDigest": "<sha256 lowercase hex>",
* "files": [{ "path": "...", "language": "...", "sizeLines": N, "fileCategory": "..." }, ...],
* "totalFiles": N,
* "filteredByIgnore": M,
@@ -45,16 +46,18 @@
* `Warning: scan-project: <path> — <reason> — file skipped from output`
* to stderr and the file is dropped; the rest of the scan completes.
*
* Determinism: files are sorted by `path.localeCompare` before emission, and
* the underlying enumeration is deterministic (git ls-files returns a stable
* order; the fallback walker sorts each directory's entries).
* Determinism: files are sorted by a locale-independent UTF-16 code-unit
* comparison before emission. The content digest uses that same order and
* length-frames each UTF-8 relative path plus its raw file bytes.
*/
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
import { dirname, resolve, join, basename, extname, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
existsSync,
lstatSync,
readFileSync,
readdirSync,
realpathSync,
@@ -458,6 +461,17 @@ function toPosix(p) {
return p.split(sep).join('/');
}
/**
* Locale-independent string order. ECMAScript relational string comparison
* is lexicographic over UTF-16 code units, so the result cannot vary with ICU,
* process locale, or operating system settings.
*/
function compareStableStrings(a, b) {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
/**
* Enumerate all files in `projectRoot` via `git ls-files`. Returns an
* array of project-relative POSIX paths, or null if the directory is not
@@ -531,7 +545,7 @@ function enumerateViaWalk(projectRoot) {
// Sort deterministically by name; mix files and dirs together so the
// final output (after the path sort) is identical regardless of
// OS-specific readdir order.
entries.sort((a, b) => a.name.localeCompare(b.name));
entries.sort((a, b) => compareStableStrings(a.name, b.name));
for (const ent of entries) {
if (ent.isDirectory()) {
if (HARD_SKIP_DIRS.has(ent.name)) continue;
@@ -621,15 +635,15 @@ function hasUserIgnoreFile(projectRoot) {
// ---------------------------------------------------------------------------
/**
* Count newline-delimited lines in a file. Returns the number of `\n`
* characters; this matches `wc -l` semantics (which counts newlines, not
* "lines of content"). Files without a trailing newline therefore report
* one fewer than the visible line count — same behavior as wc.
* Read a file once and count its newline-delimited lines. Returns both the raw
* bytes and the number of `\n` characters; the caller feeds those same bytes
* directly into the content digest without a second read or a whole-repo
* content concatenation. The count matches `wc -l` semantics.
*
* Per-file failure: emits a Warning: and returns null. Caller decides
* whether to drop the file or keep it with sizeLines=0.
*/
function countLines(absPath, posixPath) {
function readAndCountLines(absPath, posixPath) {
try {
const buf = readFileSync(absPath);
// Manual newline count beats split('\n').length on large files — no
@@ -638,7 +652,7 @@ function countLines(absPath, posixPath) {
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0x0a) count++;
}
return count;
return { bytes: buf, sizeLines: count };
} catch (err) {
process.stderr.write(
`Warning: scan-project: ${posixPath} — line count failed ` +
@@ -648,18 +662,44 @@ function countLines(absPath, posixPath) {
}
}
const CONTENT_DIGEST_DOMAIN = Buffer.from('understand-anything:scan-content:v1\0', 'utf-8');
/**
* Add one scanned regular file to the aggregate fingerprint. Entries arrive
* in compareStableStrings path order. Each frame is:
* uint32be(path UTF-8 byte length) || uint64be(content byte length) ||
* UTF-8 path bytes || raw content bytes
* Length prefixes make path/content and adjacent-entry boundaries unambiguous.
*/
function updateContentDigest(hash, posixPath, contentBytes) {
const pathBytes = Buffer.from(posixPath, 'utf-8');
const lengths = Buffer.allocUnsafe(12);
lengths.writeUInt32BE(pathBytes.length, 0);
lengths.writeBigUInt64BE(BigInt(contentBytes.length), 4);
hash.update(lengths);
hash.update(pathBytes);
hash.update(contentBytes);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const [, , projectRoot, outputPath] = process.argv;
const [, , projectRoot, outputPath, ...options] = process.argv;
if (!projectRoot || !outputPath) {
process.stderr.write(
'Usage: node scan-project.mjs <projectRoot> <outputPath>\n',
'Usage: node scan-project.mjs <projectRoot> <outputPath> ' +
'[--exclude-analysis-data]\n',
);
process.exit(1);
}
const unknownOption = options.find(option => option !== '--exclude-analysis-data');
if (unknownOption) {
process.stderr.write(`scan-project.mjs failed: unknown option: ${unknownOption}\n`);
process.exit(1);
}
const excludeAnalysisData = options.includes('--exclude-analysis-data');
if (!existsSync(projectRoot)) {
process.stderr.write(
@@ -676,7 +716,11 @@ async function main() {
}
// 1. Enumerate. Either git ls-files or recursive walk.
const candidates = enumerateFiles(projectRoot);
const candidates = enumerateFiles(projectRoot).filter(
rel =>
!excludeAnalysisData ||
(!rel.startsWith('.ua/') && !rel.startsWith('.understand-anything/')),
);
// 2. Filter via createIgnoreFilter (defaults + user .understandignore).
// Build a defaults-only filter in parallel to count user-driven drops.
@@ -701,42 +745,56 @@ async function main() {
}
}
// The per-file pass, output, stats key insertion, and content fingerprint
// all consume this one locale-independent path order.
kept.sort(compareStableStrings);
// 3. Per-file: language + category + line count.
// Drop files that fail line counting (per-file resilience).
const fileEntries = [];
const contentHash = createHash('sha256');
contentHash.update(CONTENT_DIGEST_DOMAIN);
for (const rel of kept) {
const absPath = join(projectRoot, rel);
// Stat first — git ls-files could include paths that vanished between
// listing and processing; the walker shouldn't but defensive anyway.
// lstat first so Git-enumerated symlinks are rejected before any operation
// can follow them to a repository-external target.
try {
const st = statSync(absPath);
const st = lstatSync(absPath);
if (st.isSymbolicLink()) {
process.stderr.write(
`Warning: scan-project: ${rel} — symbolic link skipped ` +
`— file skipped from output\n`,
);
continue;
}
if (!st.isFile()) {
// Symlinks-to-dir, special files, etc. — skip silently. Not a
// warning condition because git wouldn't have tracked it as a file.
// Directories and special files are not scanned as regular-file input.
continue;
}
} catch (err) {
process.stderr.write(
`Warning: scan-project: ${rel} — stat failed (${err.message}) ` +
`Warning: scan-project: ${rel}lstat failed (${err.message}) ` +
`— file skipped from output\n`,
);
continue;
}
const sizeLines = countLines(absPath, rel);
if (sizeLines === null) {
// countLines already emitted the Warning: line.
const scanned = readAndCountLines(absPath, rel);
if (scanned === null) {
// readAndCountLines already emitted the Warning: line.
continue;
}
updateContentDigest(contentHash, rel, scanned.bytes);
fileEntries.push({
path: rel,
language: detectLanguage(rel),
sizeLines,
sizeLines: scanned.sizeLines,
fileCategory: detectCategory(rel),
});
}
// 4. Determinism: sort by path.localeCompare.
fileEntries.sort((a, b) => a.path.localeCompare(b.path));
// 4. Determinism: preserve the documented locale-independent path order.
fileEntries.sort((a, b) => compareStableStrings(a.path, b.path));
const contentDigest = contentHash.digest('hex');
// 5. Stats.
const byCategory = {};
@@ -750,6 +808,7 @@ async function main() {
const output = {
scriptCompleted: true,
contentDigest,
files: fileEntries,
totalFiles: fileEntries.length,
filteredByIgnore,