From 5a7e071e9d79e96c049a58818448f7471a02140a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 11:15:01 +0200 Subject: [PATCH 1/2] CLI: replay the report of unchanged files `validate-gts` caches each file's deduplicated html-validate report, keyed by the file's content, the resolved configuration, html-validate's version, the tsconfig and the environment switches (under `.../html-validate-ember/report/`). An unchanged file is not validated again; its report is replayed into the same counters and output. Console app (838 files), warm run: 3.3 s -> 0.3 s. Output identical to an uncached run; `HVE_NO_CACHE=1` bypasses it like the other caches. Cowritten by Claude --- lib/cache.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ run.ts | 60 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/lib/cache.ts b/lib/cache.ts index 1af804e..44421b7 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -341,3 +341,75 @@ export function writeTransformCache(filename: string, key: string, templates: Ca // ignore — cache is best-effort } } +// --------------------------------------------------------------------------- +// Report cache: html-validate's (deduplicated) report for a file, keyed by +// the file's content and everything the report depends on: the resolved +// html-validate configuration, html-validate's own version, the tsconfig, +// and the environment switches. Under `.../html-validate-ember/report/`. +// --------------------------------------------------------------------------- + +export interface CachedReport { + valid: boolean; + errorCount: number; + warningCount: number; + results: Result[]; +} + +interface ReportCacheEntry extends CachedReport { + pluginVersion: string; + pluginSourceSha: string; + key: string; +} + +export function reportCacheKey(contents: string, config: unknown, htmlValidateVersion: string, tsconfigPath: string | null): string { + return sha256( + [ + contents, + JSON.stringify(config ?? null), + htmlValidateVersion, + tsconfigPath ? getTsconfigSha(tsconfigPath) : 'no-tsconfig', + process.env['HVE_GLINT'] ?? '', + process.env['HVE_TS_BACKEND'] ?? '', + process.env['HVE_MAX_CONDITIONAL_BRANCHES'] ?? '', + ].join('\0'), + ); +} + +function reportEntryPath(filename: string): string { + return path.join(findCacheDir(filename), '..', 'report', `${sha256(path.resolve(filename))}.json`); +} + +export function readReportCache(filename: string, key: string): CachedReport | null { + if (CACHE_DISABLED) return null; + let parsed: ReportCacheEntry; + try { + parsed = JSON.parse(fs.readFileSync(reportEntryPath(filename), 'utf8')) as ReportCacheEntry; + } catch { + return null; + } + if ( + parsed.pluginVersion !== PLUGIN_VERSION || + parsed.pluginSourceSha !== PLUGIN_SOURCE_SHA || + parsed.key !== key + ) { + return null; + } + return { valid: parsed.valid, errorCount: parsed.errorCount, warningCount: parsed.warningCount, results: parsed.results }; +} + +export function writeReportCache(filename: string, key: string, report: CachedReport): void { + if (CACHE_DISABLED) return; + const file = reportEntryPath(filename); + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const payload: ReportCacheEntry = { + pluginVersion: PLUGIN_VERSION, + pluginSourceSha: PLUGIN_SOURCE_SHA, + key, + ...report, + }; + fs.writeFileSync(file, JSON.stringify(payload)); + } catch { + // ignore — cache is best-effort + } +} diff --git a/run.ts b/run.ts index 9c6dfab..2fe6e34 100644 --- a/run.ts +++ b/run.ts @@ -2,12 +2,16 @@ import fs from 'node:fs'; import path from 'node:path'; import { HtmlValidate, formatterFactory } from 'html-validate'; -import type { ConfigData, Report } from 'html-validate'; +import type { ConfigData, Report, Result } from 'html-validate'; +import { createRequire } from 'node:module'; import plugin from './index.js'; import { preloadGlintFiles } from './lib/glint.js'; import type { PreloadStats } from './lib/glint.js'; import { dedupeMultipassReport } from './lib/multipass-dedupe.js'; +import { findTsconfig } from './lib/backend/index.js'; +import { readReportCache, reportCacheKey, writeReportCache } from './lib/cache.js'; +import type { CachedReport } from './lib/cache.js'; // Walk up from `start` looking for a `.htmlvalidate.json` config file // and return its parsed contents (or null if none found / unreadable). @@ -328,7 +332,40 @@ function printUsage(): void { } }; + // A file's report depends on its content, the configuration and the + // plugin; unchanged files replay their last report instead of being + // validated again. + const htmlValidateVersion = (createRequire(import.meta.url)('html-validate/package.json') as { version: string }).version; + const recordReport = (cached: CachedReport): void => { + if (cached.valid) { + valid++; + return; + } + invalid++; + totalErrors += cached.errorCount; + totalWarnings += cached.warningCount; + for (const result of cached.results) { + for (const msg of result.messages) { + ruleCounts.set(msg.ruleId, (ruleCounts.get(msg.ruleId) ?? 0) + 1); + } + } + if (!quiet) { + process.stdout.write(format(cached.results)); + } + }; for (const file of files) { + let key: string | null = null; + try { + key = reportCacheKey(fs.readFileSync(file, 'utf8'), userConfig, htmlValidateVersion, findTsconfig(file)); + } catch { + // unreadable: let validateFile report it + } + const cachedReport = key ? readReportCache(file, key) : null; + if (cachedReport) { + recordReport(cachedReport); + tickValidation(); + continue; + } let report: Report; try { report = await htmlvalidate.validateFile(file); @@ -342,6 +379,7 @@ function printUsage(): void { continue; } if (report.valid) { + if (key) writeReportCache(file, key, { valid: true, errorCount: 0, warningCount: 0, results: [] }); valid++; tickValidation(); continue; @@ -353,6 +391,14 @@ function printUsage(): void { // No-op for templates without branch points (one source → one // result → set of message keys is already unique). const deduped = dedupeMultipassReport(report); + if (key) { + writeReportCache(file, key, { + valid: deduped.valid, + errorCount: deduped.errorCount, + warningCount: deduped.warningCount, + results: deduped.valid ? [] : deduped.results, + }); + } if (deduped.valid) { // Every flagged error/warning was a multipass duplicate of one // already counted under a previous pass; the file is effectively @@ -362,17 +408,7 @@ function printUsage(): void { tickValidation(); continue; } - invalid++; - totalErrors += deduped.errorCount; - totalWarnings += deduped.warningCount; - for (const result of deduped.results) { - for (const msg of result.messages) { - ruleCounts.set(msg.ruleId, (ruleCounts.get(msg.ruleId) ?? 0) + 1); - } - } - if (!quiet) { - process.stdout.write(format(deduped.results)); - } + recordReport(deduped); tickValidation(); } From c16edf4af9de2dbad6003ea306c08e816a6fa007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 11:27:37 +0200 Subject: [PATCH 2/2] Report cache: key on the resolved backend, tests The key carries the backend kind from `backendKindFor` (as the transform cache does since #55) instead of the raw `HVE_TS_BACKEND`. Round-trip, miss-on-any-input-change and one-entry-per-path tests. Cowritten by Claude --- lib/cache.ts | 10 ++++++-- run.ts | 11 +++++++-- test/cache.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/lib/cache.ts b/lib/cache.ts index 44421b7..3bc2b37 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -361,15 +361,21 @@ interface ReportCacheEntry extends CachedReport { key: string; } -export function reportCacheKey(contents: string, config: unknown, htmlValidateVersion: string, tsconfigPath: string | null): string { +export function reportCacheKey( + contents: string, + config: unknown, + htmlValidateVersion: string, + tsconfigPath: string | null, + backendKind: string, +): string { return sha256( [ contents, JSON.stringify(config ?? null), htmlValidateVersion, tsconfigPath ? getTsconfigSha(tsconfigPath) : 'no-tsconfig', + backendKind, process.env['HVE_GLINT'] ?? '', - process.env['HVE_TS_BACKEND'] ?? '', process.env['HVE_MAX_CONDITIONAL_BRANCHES'] ?? '', ].join('\0'), ); diff --git a/run.ts b/run.ts index 2fe6e34..a606938 100644 --- a/run.ts +++ b/run.ts @@ -9,7 +9,7 @@ import plugin from './index.js'; import { preloadGlintFiles } from './lib/glint.js'; import type { PreloadStats } from './lib/glint.js'; import { dedupeMultipassReport } from './lib/multipass-dedupe.js'; -import { findTsconfig } from './lib/backend/index.js'; +import { backendKindFor, findTsconfig } from './lib/backend/index.js'; import { readReportCache, reportCacheKey, writeReportCache } from './lib/cache.js'; import type { CachedReport } from './lib/cache.js'; @@ -356,7 +356,14 @@ function printUsage(): void { for (const file of files) { let key: string | null = null; try { - key = reportCacheKey(fs.readFileSync(file, 'utf8'), userConfig, htmlValidateVersion, findTsconfig(file)); + const tsconfigPath = findTsconfig(file); + key = reportCacheKey( + fs.readFileSync(file, 'utf8'), + userConfig, + htmlValidateVersion, + tsconfigPath, + tsconfigPath ? backendKindFor(tsconfigPath) : 'none', + ); } catch { // unreadable: let validateFile report it } diff --git a/test/cache.test.ts b/test/cache.test.ts index ba4f8ab..d133c15 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -7,7 +7,16 @@ import path from 'node:path'; import os from 'node:os'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readCache, readTransformCache, transformCacheKey, writeCache, writeTransformCache } from '../lib/cache.js'; +import { + readCache, + readReportCache, + readTransformCache, + reportCacheKey, + transformCacheKey, + writeCache, + writeReportCache, + writeTransformCache, +} from '../lib/cache.js'; import type { CachedTemplate, ExtractionResult } from '../lib/cache.js'; // We need a writeable parent that has a `node_modules/` so cache.ts's @@ -176,3 +185,50 @@ describe('transform cache', () => { expect(readTransformCache(file, transformCacheKey('v1', tsconfigPath, 'ts6'))).toEqual(templates); }); }); + +describe('report cache', () => { + const report = { + valid: false, + errorCount: 1, + warningCount: 0, + results: [{ filePath: 'a.gts', messages: [{ ruleId: 'no-inline-style', severity: 2 }] }], + }; + const key = (contents: string, config: unknown = { extends: ['html-validate:recommended'] }, version = '11.0.0', backend = 'ts6') => + reportCacheKey(contents, config, version, tsconfigPath, backend); + + it('round-trips a report', () => { + const file = path.join(templatesDir, 'a.gts'); + writeReportCache(file, key('v1'), report); + expect(readReportCache(file, key('v1'))).toEqual(report); + }); + + it('misses when content, config, html-validate version, backend, tsconfig or env differ', () => { + const file = path.join(templatesDir, 'a.gts'); + writeReportCache(file, key('v1'), report); + expect(readReportCache(file, key('v2'))).toBeNull(); + expect(readReportCache(file, key('v1', { extends: [] }))).toBeNull(); + expect(readReportCache(file, key('v1', undefined, '11.1.0'))).toBeNull(); + expect(readReportCache(file, key('v1', undefined, '11.0.0', 'tsgo:typescript@7.0.0'))).toBeNull(); + fs.writeFileSync(tsconfigPath, '{"compilerOptions":{"target":"es2020"}}'); + const otherTsconfig = path.join(projectRoot, 'tsconfig.other.json'); + fs.writeFileSync(otherTsconfig, '{}'); + expect(readReportCache(file, reportCacheKey('v1', { extends: ['html-validate:recommended'] }, '11.0.0', otherTsconfig, 'ts6'))).toBeNull(); + process.env['HVE_MAX_CONDITIONAL_BRANCHES'] = '2'; + try { + expect(readReportCache(file, key('v1'))).toBeNull(); + } finally { + delete process.env['HVE_MAX_CONDITIONAL_BRANCHES']; + } + expect(readReportCache(file, key('v1'))).toEqual(report); + }); + + it('keeps one entry per file path', () => { + const file = path.join(templatesDir, 'a.gts'); + writeReportCache(file, key('v1'), report); + writeReportCache(file, key('v2'), { ...report, valid: true, errorCount: 0, results: [] }); + const dir = path.join(projectRoot, 'node_modules', '.cache', 'html-validate-ember', 'report'); + expect(fs.readdirSync(dir)).toHaveLength(1); + expect(readReportCache(file, key('v1'))).toBeNull(); + expect(readReportCache(file, key('v2'))?.valid).toBe(true); + }); +});