fix(bench): isolate reproducible benchmark inputs
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user