Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,3 +341,81 @@ 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<Result> {
valid: boolean;
errorCount: number;
warningCount: number;
results: Result[];
}

interface ReportCacheEntry<Result> extends CachedReport<Result> {
pluginVersion: string;
pluginSourceSha: string;
key: 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_MAX_CONDITIONAL_BRANCHES'] ?? '',
].join('\0'),
);
}

function reportEntryPath(filename: string): string {
return path.join(findCacheDir(filename), '..', 'report', `${sha256(path.resolve(filename))}.json`);
}

export function readReportCache<Result>(filename: string, key: string): CachedReport<Result> | null {
if (CACHE_DISABLED) return null;
let parsed: ReportCacheEntry<Result>;
try {
parsed = JSON.parse(fs.readFileSync(reportEntryPath(filename), 'utf8')) as ReportCacheEntry<Result>;
} 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<Result>(filename: string, key: string, report: CachedReport<Result>): void {
if (CACHE_DISABLED) return;
const file = reportEntryPath(filename);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
const payload: ReportCacheEntry<Result> = {
pluginVersion: PLUGIN_VERSION,
pluginSourceSha: PLUGIN_SOURCE_SHA,
key,
...report,
};
fs.writeFileSync(file, JSON.stringify(payload));
} catch {
// ignore — cache is best-effort
}
}
Comment thread
johanrd marked this conversation as resolved.
67 changes: 55 additions & 12 deletions run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { backendKindFor, 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).
Expand Down Expand Up @@ -328,7 +332,47 @@ 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<Result>): 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 {
const tsconfigPath = findTsconfig(file);
key = reportCacheKey(
fs.readFileSync(file, 'utf8'),
userConfig,
htmlValidateVersion,
tsconfigPath,
tsconfigPath ? backendKindFor(tsconfigPath) : 'none',
);
} catch {
// unreadable: let validateFile report it
}
const cachedReport = key ? readReportCache<Result>(file, key) : null;
if (cachedReport) {
recordReport(cachedReport);
tickValidation();
continue;
}
let report: Report;
try {
report = await htmlvalidate.validateFile(file);
Expand All @@ -342,6 +386,7 @@ function printUsage(): void {
continue;
}
if (report.valid) {
if (key) writeReportCache(file, key, { valid: true, errorCount: 0, warningCount: 0, results: [] });
valid++;
tickValidation();
continue;
Expand All @@ -353,6 +398,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
Expand All @@ -362,17 +415,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();
}

Expand Down
58 changes: 57 additions & 1 deletion test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
Loading