|
8 | 8 | /** |
9 | 9 | * Extension folder change detection for hot-reload. |
10 | 10 | * |
11 | | - * Timestamp strategy: |
12 | | - * - Read the most recent mtimeMs across all tracked extension files. |
13 | | - * - Compare it against the debug window session start time. |
14 | | - * - If newest mtimeMs > sessionStartMs, trigger extension hot-reload. |
| 11 | + * Two-phase detection strategy: |
| 12 | + * 1. Fast mtime check: compares newest source mtime against newest dist/ mtime. |
| 13 | + * 2. Content hash verification: when mtime suggests staleness, computes a |
| 14 | + * SHA-256 fingerprint of all source file contents and compares against |
| 15 | + * the stored fingerprint. This prevents false positives from operations |
| 16 | + * that update file metadata without changing content (e.g. `git add`). |
15 | 17 | * |
16 | 18 | * Tracked files are filtered by: |
17 | 19 | * 1. Built-in ignore defaults (node_modules, dist, .git, *.vsix) |
18 | 20 | * 2. Optional `<extensionRoot>/.devtoolsignore` patterns |
19 | 21 | */ |
20 | 22 |
|
21 | | -import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs'; |
| 23 | +import {createHash} from 'node:crypto'; |
| 24 | +import {existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync} from 'node:fs'; |
22 | 25 | import path, {extname, join, relative} from 'node:path'; |
23 | 26 |
|
24 | 27 | import {logger} from './logger.js'; |
25 | 28 |
|
26 | 29 | const IGNORE_DIRS = new Set(['node_modules', 'dist', '.git']); |
27 | 30 | const IGNORE_EXTENSIONS = new Set(['.vsix']); |
28 | 31 | const DEVTOOLS_IGNORE_FILENAME = '.devtoolsignore'; |
| 32 | +const EXT_FINGERPRINT_DIR = '.devtools'; |
| 33 | +const EXT_FINGERPRINT_FILE = 'ext-source-fingerprint.json'; |
29 | 34 |
|
30 | 35 | interface IgnoreRule { |
31 | 36 | pattern: string; |
@@ -191,6 +196,89 @@ export function getNewestTrackedChangeTime(extensionDir: string): number { |
191 | 196 | return result.newestMtimeMs; |
192 | 197 | } |
193 | 198 |
|
| 199 | +// ── Content Hashing ────────────────────────────────────── |
| 200 | + |
| 201 | +function hashDirectoryContents( |
| 202 | + dir: string, |
| 203 | + rootDir: string, |
| 204 | + rules: IgnoreRule[], |
| 205 | + hash: ReturnType<typeof createHash>, |
| 206 | +): void { |
| 207 | + let entries: string[]; |
| 208 | + try { |
| 209 | + entries = readdirSync(dir, {encoding: 'utf8'}); |
| 210 | + } catch { |
| 211 | + return; |
| 212 | + } |
| 213 | + entries.sort(); |
| 214 | + |
| 215 | + for (const name of entries) { |
| 216 | + const fullPath = join(dir, name); |
| 217 | + let stat: ReturnType<typeof statSync>; |
| 218 | + try { |
| 219 | + stat = statSync(fullPath); |
| 220 | + } catch { |
| 221 | + continue; |
| 222 | + } |
| 223 | + |
| 224 | + if (stat.isDirectory()) { |
| 225 | + if (shouldIgnorePath(rootDir, fullPath, true, rules)) continue; |
| 226 | + hashDirectoryContents(fullPath, rootDir, rules, hash); |
| 227 | + } else if (stat.isFile()) { |
| 228 | + if (shouldIgnorePath(rootDir, fullPath, false, rules)) continue; |
| 229 | + const rel = path.posix.normalize(relative(rootDir, fullPath).replaceAll('\\', '/')); |
| 230 | + hash.update(rel); |
| 231 | + try { |
| 232 | + hash.update(readFileSync(fullPath)); |
| 233 | + } catch { |
| 234 | + // skip unreadable files |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | +} |
| 239 | + |
| 240 | +function computeExtSourceFingerprint(extensionDir: string, rules: IgnoreRule[]): string { |
| 241 | + const hash = createHash('sha256'); |
| 242 | + hashDirectoryContents(extensionDir, extensionDir, rules, hash); |
| 243 | + return hash.digest('hex'); |
| 244 | +} |
| 245 | + |
| 246 | +function readExtFingerprint(extensionDir: string): string | null { |
| 247 | + const fp = join(extensionDir, EXT_FINGERPRINT_DIR, EXT_FINGERPRINT_FILE); |
| 248 | + if (!existsSync(fp)) return null; |
| 249 | + try { |
| 250 | + const raw = readFileSync(fp, 'utf8'); |
| 251 | + const data: unknown = JSON.parse(raw); |
| 252 | + if (typeof data === 'object' && data !== null && 'hash' in data) { |
| 253 | + const hash = (data as Record<string, unknown>).hash; |
| 254 | + if (typeof hash === 'string') return hash; |
| 255 | + } |
| 256 | + return null; |
| 257 | + } catch { |
| 258 | + return null; |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +/** |
| 263 | + * Persist the current extension source fingerprint so future checks can skip |
| 264 | + * rebuilds when only file metadata (not content) changed. |
| 265 | + */ |
| 266 | +export function writeExtSourceFingerprint(extensionDir: string): void { |
| 267 | + const rules = parseIgnoreRules(extensionDir); |
| 268 | + const hash = computeExtSourceFingerprint(extensionDir, rules); |
| 269 | + const dir = join(extensionDir, EXT_FINGERPRINT_DIR); |
| 270 | + try { |
| 271 | + mkdirSync(dir, {recursive: true}); |
| 272 | + writeFileSync( |
| 273 | + join(dir, EXT_FINGERPRINT_FILE), |
| 274 | + JSON.stringify({hash, computedAt: Date.now()}), |
| 275 | + ); |
| 276 | + logger(`[hot-reload] Extension fingerprint written: ${hash.slice(0, 12)}…`); |
| 277 | + } catch (err) { |
| 278 | + logger(`[hot-reload] Failed to write extension fingerprint: ${err}`); |
| 279 | + } |
| 280 | +} |
| 281 | + |
194 | 282 | /** |
195 | 283 | * Check whether extension files changed after the debug window started. |
196 | 284 | * |
@@ -269,35 +357,50 @@ export function getNewestBuildMtime(extensionDir: string): number { |
269 | 357 | /** |
270 | 358 | * Check if the extension build is stale (source files newer than build output). |
271 | 359 | * |
272 | | - * This compares the newest source file mtime against the newest dist/ file mtime. |
273 | | - * If source is newer, the build is stale and needs hot-reload. |
| 360 | + * Uses a two-phase approach: |
| 361 | + * 1. Fast mtime comparison of source vs dist/ output. |
| 362 | + * 2. Content hash verification when mtime suggests staleness, to filter |
| 363 | + * out metadata-only changes (e.g. git staging). |
274 | 364 | * |
275 | 365 | * @param extensionDir Extension root directory |
276 | | - * @returns true if source files are newer than build output (needs rebuild) |
| 366 | + * @returns true if source content has actually changed since last build |
277 | 367 | */ |
278 | 368 | export function isBuildStale(extensionDir: string): boolean { |
279 | 369 | const sourceNewest = getNewestTrackedChangeTime(extensionDir); |
280 | 370 | const buildNewest = getNewestBuildMtime(extensionDir); |
281 | 371 |
|
282 | | - // If no build exists, definitely stale |
283 | 372 | if (buildNewest === 0) { |
284 | 373 | logger(`[hot-reload] No build found in dist/ — build is stale`); |
285 | 374 | return true; |
286 | 375 | } |
287 | 376 |
|
288 | | - const stale = sourceNewest > buildNewest; |
289 | | - |
290 | | - if (stale) { |
| 377 | + if (sourceNewest <= buildNewest) { |
291 | 378 | logger( |
292 | | - `[hot-reload] Build STALE: source=${new Date(sourceNewest).toISOString()} > build=${new Date(buildNewest).toISOString()}`, |
| 379 | + `[hot-reload] Build up-to-date: source=${new Date(sourceNewest).toISOString()} <= build=${new Date(buildNewest).toISOString()}`, |
293 | 380 | ); |
294 | | - } else { |
| 381 | + return false; |
| 382 | + } |
| 383 | + |
| 384 | + // Mtime says stale — verify with content hash |
| 385 | + logger( |
| 386 | + `[hot-reload] Mtime suggests stale: source=${new Date(sourceNewest).toISOString()} > build=${new Date(buildNewest).toISOString()} — verifying content…`, |
| 387 | + ); |
| 388 | + |
| 389 | + const rules = parseIgnoreRules(extensionDir); |
| 390 | + const currentHash = computeExtSourceFingerprint(extensionDir, rules); |
| 391 | + const storedHash = readExtFingerprint(extensionDir); |
| 392 | + |
| 393 | + if (storedHash && currentHash === storedHash) { |
295 | 394 | logger( |
296 | | - `[hot-reload] Build up-to-date: source=${new Date(sourceNewest).toISOString()} <= build=${new Date(buildNewest).toISOString()}`, |
| 395 | + `[hot-reload] Content unchanged (fingerprint=${currentHash.slice(0, 12)}…) — metadata-only change, skipping rebuild`, |
297 | 396 | ); |
| 397 | + return false; |
298 | 398 | } |
299 | 399 |
|
300 | | - return stale; |
| 400 | + logger( |
| 401 | + `[hot-reload] Content changed: ${storedHash ? `${storedHash.slice(0, 12)}… → ${currentHash.slice(0, 12)}…` : `new fingerprint ${currentHash.slice(0, 12)}…`}`, |
| 402 | + ); |
| 403 | + return true; |
301 | 404 | } |
302 | 405 |
|
303 | 406 | /** |
|
0 commit comments