|
4 | 4 | import fs from 'node:fs'; |
5 | 5 | import os from 'node:os'; |
6 | 6 | import path from 'node:path'; |
7 | | -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 7 | +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; |
8 | 8 | import { closeDb, initSchema, openDb } from '../../src/db/index.js'; |
9 | 9 | import { PipelineContext } from '../../src/domain/graph/builder/context.js'; |
| 10 | +import { fileStat } from '../../src/domain/graph/builder/helpers.js'; |
10 | 11 | import { |
11 | 12 | detectChanges, |
12 | 13 | detectNoChanges, |
@@ -62,12 +63,12 @@ describe('detectChanges stage', () => { |
62 | 63 | const content = fs.readFileSync(path.join(dir, 'a.js'), 'utf-8'); |
63 | 64 | const { createHash } = await import('node:crypto'); |
64 | 65 | const hash = createHash('md5').update(content).digest('hex'); |
65 | | - const stat = fs.statSync(path.join(dir, 'a.js')); |
| 66 | + const stat = fs.statSync(path.join(dir, 'a.js'), { bigint: true }); |
66 | 67 | db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run( |
67 | 68 | 'a.js', |
68 | 69 | hash, |
69 | | - Math.floor(stat.mtimeMs), |
70 | | - stat.size, |
| 70 | + Number(stat.mtimeNs / 1_000_000n), |
| 71 | + Number(stat.size), |
71 | 72 | ); |
72 | 73 |
|
73 | 74 | // Write journal header so journal check doesn't confuse things |
@@ -158,15 +159,16 @@ describe('detectNoChanges fast-skip', () => { |
158 | 159 | relPath: string, |
159 | 160 | filePath: string, |
160 | 161 | ): { mtime: number; size: number } { |
161 | | - const stat = fs.statSync(filePath); |
162 | | - const mtime = Math.floor(stat.mtimeMs); |
| 162 | + const stat = fs.statSync(filePath, { bigint: true }); |
| 163 | + const mtime = Number(stat.mtimeNs / 1_000_000n); |
| 164 | + const size = Number(stat.size); |
163 | 165 | db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run( |
164 | 166 | relPath, |
165 | 167 | 'deadbeef', |
166 | 168 | mtime, |
167 | | - stat.size, |
| 169 | + size, |
168 | 170 | ); |
169 | | - return { mtime, size: stat.size }; |
| 171 | + return { mtime, size }; |
170 | 172 | } |
171 | 173 |
|
172 | 174 | it('returns false when file_hashes is empty (first build)', () => { |
@@ -221,12 +223,12 @@ describe('detectNoChanges fast-skip', () => { |
221 | 223 | const db = openDb(path.join(dbDir, 'graph.db')); |
222 | 224 | initSchema(db); |
223 | 225 | const file = seedFile(dir, 'a.js', 'export const a = 1;'); |
224 | | - const stat = fs.statSync(file); |
| 226 | + const stat = fs.statSync(file, { bigint: true }); |
225 | 227 | db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run( |
226 | 228 | 'a.js', |
227 | 229 | 'deadbeef', |
228 | | - Math.floor(stat.mtimeMs) + 1000, // skewed mtime |
229 | | - stat.size, |
| 230 | + Number(stat.mtimeNs / 1_000_000n) + 1000, // skewed mtime |
| 231 | + Number(stat.size), |
230 | 232 | ); |
231 | 233 |
|
232 | 234 | expect(detectNoChanges(db, [file], dir)).toBe(false); |
@@ -276,4 +278,56 @@ describe('detectNoChanges fast-skip', () => { |
276 | 278 | closeDb(db); |
277 | 279 | fs.rmSync(dir, { recursive: true, force: true }); |
278 | 280 | }); |
| 281 | + |
| 282 | + // Pins down the BigInt-nanosecond truncation the helper uses to match Rust's |
| 283 | + // `Duration::as_millis() as i64`. We can't trigger the f64 ULP rounding bug |
| 284 | + // with a freshly-created file (the failure window is ~256 ns out of every ms, |
| 285 | + // ~0.026% of values), so instead we stub `fs.statSync` to return a hand-picked |
| 286 | + // BigInt `mtimeNs` whose f64-mtimeMs path diverges from the BigInt path: |
| 287 | + // ns = 1748400000000999808n (≈ 2025-05-28 epoch ns) |
| 288 | + // BigInt: Number(ns / 1_000_000n) === 1748400000000 |
| 289 | + // f64 (broken): Math.floor(Number(ns) / 1e6) === 1748400000001 |
| 290 | + // Reverting `fileStat` to `Math.floor(stat.mtimeMs)` would flip the result to |
| 291 | + // N+1 and fail the assertion deterministically — re-introducing #1075 (the |
| 292 | + // Rust-written `file_hashes.mtime` of N reading back as N+1 in JS, busting |
| 293 | + // the fast-skip path on every native→JS handoff). |
| 294 | + describe('fileStat #1075 mtime truncation', () => { |
| 295 | + afterEach(() => { |
| 296 | + vi.restoreAllMocks(); |
| 297 | + }); |
| 298 | + |
| 299 | + it('matches Rust Duration::as_millis() truncation at f64-rounding boundary', () => { |
| 300 | + // Hand-picked epoch ns where Number(ns)/1e6 rounds up across a ms boundary. |
| 301 | + const badMtimeNs = 1748400000000999808n; |
| 302 | + const truncatedMs = 1748400000000; |
| 303 | + const roundedMs = 1748400000001; |
| 304 | + |
| 305 | + // Sanity: confirm the chosen value actually triggers the divergence; if a |
| 306 | + // future Node.js release changes f64 rounding, this baseline assertion |
| 307 | + // catches it before we trust the spy-based test below. |
| 308 | + expect(Number(badMtimeNs / 1_000_000n)).toBe(truncatedMs); |
| 309 | + expect(Math.floor(Number(badMtimeNs) / 1e6)).toBe(roundedMs); |
| 310 | + |
| 311 | + const stubStats = { |
| 312 | + mtimeNs: badMtimeNs, |
| 313 | + mtimeMs: Number(badMtimeNs) / 1e6, |
| 314 | + size: 42n, |
| 315 | + } as unknown as fs.BigIntStats; |
| 316 | + vi.spyOn(fs, 'statSync').mockReturnValue(stubStats); |
| 317 | + |
| 318 | + // BigInt path must win: N, not N+1. |
| 319 | + expect(fileStat('/fake/path.js')?.mtime).toBe(truncatedMs); |
| 320 | + }); |
| 321 | + |
| 322 | + it('returns the BigInt-truncated mtime for a real file on disk', () => { |
| 323 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fileStat-trunc-')); |
| 324 | + const file = seedFile(dir, 'a.js', 'export const a = 1;'); |
| 325 | + |
| 326 | + const big = fs.statSync(file, { bigint: true }); |
| 327 | + const expected = Number(big.mtimeNs / 1_000_000n); |
| 328 | + expect(fileStat(file)?.mtime).toBe(expected); |
| 329 | + |
| 330 | + fs.rmSync(dir, { recursive: true, force: true }); |
| 331 | + }); |
| 332 | + }); |
279 | 333 | }); |
0 commit comments