From 374f9ce3616dda5ed689676e2bec4f6cd4fefdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 13:21:59 +0200 Subject: [PATCH 1/3] Release review fixes: closure with Glint off, stable keys, editor memos - The closure is part of the transform and report keys with Glint off too: the resolver reads an imported component's template to substitute its tag. - A `.ts`/`.js` module's co-located `.hbs` template (next to it or under `templates/components/`) and `/// ` targets are part of the closure. Without a tsconfig, relative imports still resolve. - The lockfile is found at or above the tsconfig directory (workspace root). A nested directory with its own package.json is not walked for project-wide inputs. - The tsconfig sha covers the whole `extends` chain and is re-validated against the file system; `backendKindFor` makes the same decision as `selectBackend` (a TypeScript 7 package that does not load falls back to ts6 in both). - A result computed after a Glint extraction threw is not cached at any level; the next run retries. - Long-lived hosts: the project-wide input list re-validates on the mtimes of the directories walked; an `index` probe watches the directory it lives in; trailing commas are removed outside strings only; file records keep import lists, not content. - The CLI computes report keys before the Glint preload and preloads the misses only; `dependencySha` is memoised per content under a static file system. `--help` names all three caches. Cowritten by Claude --- lib/backend/index.ts | 14 +-- lib/cache.ts | 26 ++--- lib/deps.ts | 257 ++++++++++++++++++++++++++++--------------- run.ts | 57 ++++++---- test/cache.test.ts | 6 +- test/deps.test.ts | 84 ++++++++++++-- transform.ts | 12 +- 7 files changed, 310 insertions(+), 146 deletions(-) diff --git a/lib/backend/index.ts b/lib/backend/index.ts index 8b7dd1c..5635ce7 100644 --- a/lib/backend/index.ts +++ b/lib/backend/index.ts @@ -11,7 +11,7 @@ import { createRequire } from 'node:module'; import type * as TS from 'typescript'; import { createTs6Backend, loadTs6Deps, ts6Syntax } from './ts6.js'; -import { createTsgoBackend, loadTsgo, resolveTsgoPackage } from './tsgo.js'; +import { createTsgoBackend, loadTsgo } from './tsgo.js'; import type { TsSyntax, TypeBackend } from './types.js'; export type { @@ -68,17 +68,17 @@ function declaresContentMappers(tsconfigPath: string): boolean { } /** - * Which backend `backendFor` would pick, as a cache-key component, without - * loading TypeScript: the forced kind, or tsgo (with its package and - * version) when the tsconfig declares `contentMappers` and a TypeScript 7 - * package resolves. + * Which backend `backendFor` would pick, as a cache-key component: the + * forced kind, or tsgo (with its package and version) when the tsconfig + * declares `contentMappers` and a TypeScript 7 package loads. Same + * decision as `selectBackend`, without opening a project. */ export function backendKindFor(tsconfigPath: string): string { const forced = process.env['HVE_TS_BACKEND']; if (forced === 'ts6') return 'ts6'; if (forced === 'tsgo' || declaresContentMappers(tsconfigPath)) { - const pkg = resolveTsgoPackage(path.dirname(tsconfigPath)); - if (pkg) return `tsgo:${pkg.name}@${pkg.version}`; + const mods = loadTsgo(path.dirname(tsconfigPath)); + if (mods) return `tsgo:${mods.packageName}@${mods.version}`; if (forced === 'tsgo') return 'none'; } return 'ts6'; diff --git a/lib/cache.ts b/lib/cache.ts index 5e07708..1e0d8ce 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -38,7 +38,7 @@ import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import type { ComponentAttrs } from './builtin-components.js'; -import { dependencySha, sha256 } from './deps.js'; +import { dependencySha, sha256, tsconfigChainSha } from './deps.js'; // Walk up from this module looking for the nearest `package.json` so the // version is found regardless of whether we're running from source @@ -139,22 +139,10 @@ interface CacheEntry { componentAttrMap: Array<[string, ComponentAttrs]>; } -// In-memory cache for the SHA of each tsconfig file (read once per -// process; tsconfigs rarely change mid-run). -const tsconfigShaCache = new Map(); - +// The tsconfig and every config it extends; `lib/deps.ts` re-validates +// the chain against the file system. function getTsconfigSha(tsconfigPath: string): string { - const cached = tsconfigShaCache.get(tsconfigPath); - if (cached !== undefined) return cached; - let sha: string; - try { - const contents = fs.readFileSync(tsconfigPath, 'utf8'); - sha = sha256(contents); - } catch { - sha = 'no-tsconfig'; - } - tsconfigShaCache.set(tsconfigPath, sha); - return sha; + return tsconfigChainSha(tsconfigPath); } // Walk up from a file to find the project root (where node_modules/ @@ -306,10 +294,10 @@ interface TransformCacheEntry { } /** Everything the transform's output depends on besides the plugin itself. */ -// Without Glint nothing crosses file boundaries, so the closure is not -// part of the key (and not computed). +// The closure matters with Glint off too: the resolver reads an imported +// component's template to substitute its tag. function dependenciesForKey(filename: string, contents: string, tsconfigPath: string | null): string { - return process.env['HVE_GLINT'] === '0' ? 'no-glint' : dependencySha(filename, contents, tsconfigPath); + return CACHE_DISABLED ? 'disabled' : dependencySha(filename, contents, tsconfigPath); } export function transformCacheKey(filename: string, data: string, tsconfigPath: string | null, backendKind: string): string { diff --git a/lib/deps.ts b/lib/deps.ts index 8ba72f9..773183a 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -1,27 +1,32 @@ -// What a file's Glint result depends on besides its own content: the -// project files it imports, transitively, plus the project-wide inputs -// that reach every file without an import (ambient declarations, module +// What a file's result depends on besides its own content: the project +// files it imports, transitively, plus the project-wide inputs that reach +// every file without an import (ambient declarations, module // augmentations, installed packages). The cache keys include // `dependencySha`, so a change anywhere upstream misses for every file // downstream — the shape of tsc's incremental `referencedMap`, keyed on // file content rather than on the exported signature, so stricter than -// tsc, never looser. +// tsc, never looser. The closure is needed with Glint off too: the +// resolver reads an imported component's template to substitute its tag. // -// Imports are found by scanning the text for specifiers, not by parsing. +// Imports are found by scanning the text for specifiers (`from`, +// `import`, `import()`, `/// `), not by parsing. // Resolution follows tsc: relative paths; tsconfig `paths` (longest // prefix wins) against `baseUrl` or the directory of the config that // declares them; `baseUrl`; `extends` (relative, and packages through // their `tsconfig` field or `tsconfig.json`), later entries overriding // earlier ones; TypeScript's `.js` → `.ts` rewrite; extension probing -// with `.gts`/`.gjs` first; directory `index` files. A resolved file is -// taken by its real path; anything under `node_modules` is a package and -// covered by the lockfile sha. Workspace sources reached through a -// symlink or a `../` path are project files. +// with `.gts`/`.gjs` first; directory `index` files. A `.ts`/`.js` +// module's co-located `.hbs` template counts as part of it. A resolved +// file is taken by its real path; anything under `node_modules` is a +// package and covered by the lockfile sha. Workspace sources reached +// through a symlink or a `../` path are project files. Without a +// tsconfig only relative imports resolve. // // Memos re-validate against the file system: file records on mtime and // size, module resolution on the mtime of every directory it probed, -// tsconfig paths on the config chain's content. The list of project-wide -// input files is found once per process; their content is re-checked. +// tsconfig paths on the config chain's content, the list of project-wide +// inputs on the mtime of the directories walked. `assumeStaticFileSystem` +// (the CLI) trusts them for the rest of the process. import fs from 'node:fs'; import path from 'node:path'; @@ -37,28 +42,36 @@ export function sha256(input: string | Buffer): string { return crypto.createHash('sha256').update(input).digest('hex'); } +const warned = new Set(); function warnOnce(key: string, message: string): void { if (warned.has(key)) return; warned.add(key); process.stderr.write(`[html-validate-ember] ${message}\n`); } -const warned = new Set(); // A one-shot run (the CLI) sees the file system as it was at start, like a // non-watch `tsc`: memos are trusted after their first fill. A long-lived -// host keeps re-validating them. +// host keeps re-validating. let staticFileSystem = false; export function assumeStaticFileSystem(): void { staticFileSystem = true; } +function mtimeOf(p: string): number { + try { + return fs.statSync(p).mtimeMs; + } catch { + return -1; + } +} + // --- file content, memoised on mtime and size ------------------------------- interface FileRecord { mtimeMs: number; size: number; sha: string; - /** Specifiers, scanned on first use. */ + /** Specifiers, scanned on first use; the content is not kept. */ imports: () => string[]; } const fileRecords = new Map(); @@ -77,30 +90,41 @@ function fileRecord(file: string): FileRecord | null { if (!stat.isFile()) return null; const cached = fileRecords.get(file); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached; - let contents: string; + let contents: string | null; try { contents = fs.readFileSync(file, 'utf8'); } catch { return null; } let imports: string[] | undefined; - const record = { + const record: FileRecord = { mtimeMs: stat.mtimeMs, size: stat.size, sha: sha256(contents), - imports: () => (imports ??= importSpecifiers(contents)), + imports: () => { + if (!imports) { + imports = importSpecifiers(contents!); + contents = null; + } + return imports; + }, }; fileRecords.set(file, record); return record; } -/** `from '...'`, `import '...'` and `import('...')` specifiers, in order of appearance. */ +/** Import specifiers and `/// ` targets, in order of appearance. */ export function importSpecifiers(contents: string): string[] { const found: string[] = []; - const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"\n]+)['"]/g; - for (const match of contents.matchAll(pattern)) { - const spec = match[1]; - if (spec && !found.includes(spec)) found.push(spec); + const patterns = [ + /(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"\n]+)['"]/g, + /^\s*\/\/\/\s*; } +const NO_TSCONFIG: ProjectPaths = { id: 0, baseUrl: null, pathsBase: null, paths: [], chain: [] }; const projectPathsByTsconfig = new Map(); let projectPathsReads = 0; -// tsconfig.json is JSON with comments and trailing commas. +// tsconfig.json is JSON with comments and trailing commas. Strings are +// copied verbatim; comments and trailing commas are removed outside them. export function parseJsonc(text: string): unknown { let out = ''; let i = 0; @@ -139,12 +165,21 @@ export function parseJsonc(text: string): unknown { } else if (ch === '/' && text[i + 1] === '*') { const end = text.indexOf('*/', i + 2); i = end === -1 ? text.length : end + 2; + } else if (ch === ',') { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] === '}' || text[j] === ']') { + i++; + } else { + out += ch; + i++; + } } else { out += ch; i++; } } - return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')); + return JSON.parse(out); } interface TsconfigShape { @@ -168,7 +203,10 @@ function readTsconfigChain(tsconfigPath: string, seen = new Set()): Proj try { config = (parseJsonc(fs.readFileSync(tsconfigPath, 'utf8')) ?? {}) as TsconfigShape; } catch (err) { - warnOnce(`tsconfig:${tsconfigPath}`, `${tsconfigPath}: cannot parse (${err instanceof Error ? err.message : String(err)}); imports through tsconfig paths are not tracked for the cache.`); + warnOnce( + `tsconfig:${tsconfigPath}`, + `${tsconfigPath}: cannot parse (${err instanceof Error ? err.message : String(err)}); imports through tsconfig paths are not tracked for the cache.`, + ); return result; } const dir = path.dirname(tsconfigPath); @@ -219,7 +257,8 @@ function resolveExtends(spec: string, fromDir: string): string | null { return null; } -function projectPaths(tsconfigPath: string): ProjectPaths { +function projectPaths(tsconfigPath: string | null): ProjectPaths { + if (!tsconfigPath) return NO_TSCONFIG; const cached = projectPathsByTsconfig.get(tsconfigPath); if (cached && (staticFileSystem || cached.chain.every(([file, sha]) => fileRecord(file)?.sha === sha))) return cached; const fresh = readTsconfigChain(tsconfigPath); @@ -227,6 +266,11 @@ function projectPaths(tsconfigPath: string): ProjectPaths { return fresh; } +/** Sha over the tsconfig and every config it extends. */ +export function tsconfigChainSha(tsconfigPath: string): string { + return sha256(projectPaths(tsconfigPath).chain.map(([file, sha]) => `${file}\0${sha}`).join('\n')); +} + // --- module resolution ----------------------------------------------------------- interface Resolution { @@ -235,38 +279,34 @@ interface Resolution { dirs: string[]; } const resolutionByKey = new Map(); +const probeByCandidate = new Map(); const dirMtimes = new Map(); -function dirMtime(dir: string): number { - try { - return fs.statSync(dir).mtimeMs; - } catch { - return -1; - } -} - -// Drops memoised resolutions that probed a directory whose mtime moved — -// a file was created, deleted or renamed there. One stat per known -// directory per closure walk. +// Drops memoised resolutions and probes that touched a directory whose +// mtime moved — a file was created, deleted or renamed there. function revalidateDirectories(): void { if (staticFileSystem) return; const changed = new Set(); for (const [dir, mtime] of dirMtimes) { - const now = dirMtime(dir); + const now = mtimeOf(dir); if (now !== mtime) { changed.add(dir); dirMtimes.set(dir, now); } } if (changed.size === 0) return; - for (const [key, resolution] of resolutionByKey) { - if (resolution.dirs.some((dir) => changed.has(dir))) resolutionByKey.delete(key); - } - for (const [candidate, probe] of probeByCandidate) { - if (probe.dirs.some((dir) => changed.has(dir))) probeByCandidate.delete(candidate); + for (const map of [resolutionByKey, probeByCandidate]) { + for (const [key, entry] of map) { + if (entry.dirs.some((dir) => changed.has(dir))) map.delete(key); + } } } +function watch(dir: string, probed: Set): void { + probed.add(dir); + if (!dirMtimes.has(dir)) dirMtimes.set(dir, mtimeOf(dir)); +} + // The directory whose mtime moves when a file at `p` is created: the // nearest existing ancestor (creating `types/@ember/service.d.ts` first // changes `types/`). @@ -277,10 +317,7 @@ function watchedDirectory(p: string): string { } // A candidate path is probed once; the same `types/@ember/service` is -// tried from every importing directory. Dropped with the memoised -// resolutions when a watched directory changes. -const probeByCandidate = new Map(); - +// tried from every importing directory. function probeFile(candidate: string, probed: Set): string | null { const memo = probeByCandidate.get(candidate); if (memo) { @@ -288,25 +325,25 @@ function probeFile(candidate: string, probed: Set): string | null { return memo.file; } const dirs = new Set(); - const file = probeUncached(candidate, dirs); - for (const dir of dirs) probed.add(dir); - probeByCandidate.set(candidate, { file, dirs: [...dirs] }); - return file; -} - -function probeUncached(candidate: string, probed: Set): string | null { + watch(watchedDirectory(candidate), dirs); + // `index` files live one level deeper. + if (fs.existsSync(candidate)) watch(candidate, dirs); const stem = candidate.replace(/\.(?:js|mjs|cjs|jsx)$/, ''); const attempts = [candidate, ...EXTENSIONS.map((ext) => stem + ext), ...EXTENSIONS.map((ext) => path.join(candidate, `index${ext}`))]; - probed.add(watchedDirectory(candidate)); + let file: string | null = null; for (const attempt of attempts) { try { - if (fs.statSync(attempt).isFile()) return fs.realpathSync.native(attempt); + if (fs.statSync(attempt).isFile()) { + file = fs.realpathSync.native(attempt); + break; + } } catch { // next } } - if (fs.existsSync(candidate)) probed.add(candidate); - return null; + for (const dir of dirs) probed.add(dir); + probeByCandidate.set(candidate, { file, dirs: [...dirs] }); + return file; } // tsc's findBestPatternMatch: the pattern with the longest prefix wins. @@ -329,7 +366,7 @@ function matchPaths(paths: Array<[string, string[]]>, spec: string): { wildcard: } /** The project file `spec` refers to from `fromFile`, or null when it is a package or unresolved. */ -export function resolveProjectImport(spec: string, fromFile: string, tsconfigPath: string): string | null { +export function resolveProjectImport(spec: string, fromFile: string, tsconfigPath: string | null): string | null { return resolveWith(spec, fromFile, projectPaths(tsconfigPath)); } @@ -339,9 +376,6 @@ function resolveWith(spec: string, fromFile: string, paths: ProjectPaths): strin if (memo) return memo.file; const probed = new Set(); const file = resolveUncached(spec, fromFile, paths, probed); - for (const dir of probed) { - if (!dirMtimes.has(dir)) dirMtimes.set(dir, dirMtime(dir)); - } resolutionByKey.set(memoKey, { file, dirs: [...probed] }); return file; } @@ -365,15 +399,25 @@ function resolveUncached(spec: string, fromFile: string, { baseUrl, pathsBase, p return null; } +// A `.ts`/`.js` module's template can live next to it or under +// `templates/components/`; the resolver reads it, so it is part of the +// module for the cache. +function hbsPeers(file: string): string[] { + const m = /^(.*)\/components\/([^/]+)\.(?:ts|js)$/.exec(file); + const peers = [file.replace(/\.(?:ts|js)$/, '.hbs')]; + if (m) peers.push(path.join(m[1]!, 'templates', 'components', `${m[2]!}.hbs`)); + return peers.filter((peer) => peer !== file && fileRecord(peer) !== null); +} + // --- closure ---------------------------------------------------------------------- -/** Project files `file` imports, transitively (real absolute paths, sorted, without `file` itself). */ // Directories are re-validated once per `dependencySha` (which walks // many closures), and on every direct call. let insideSha = false; let revalidatedInsideSha = false; -export function dependencyClosure(file: string, contents: string, tsconfigPath: string, specifiers = importSpecifiers(contents)): string[] { +/** Project files `file` imports, transitively (real absolute paths, sorted, without `file` itself). */ +export function dependencyClosure(file: string, contents: string, tsconfigPath: string | null, specifiers = importSpecifiers(contents)): string[] { if (!insideSha || !revalidatedInsideSha) { revalidateDirectories(); revalidatedInsideSha = insideSha; @@ -382,12 +426,14 @@ export function dependencyClosure(file: string, contents: string, tsconfigPath: const root = path.resolve(file); const seen = new Set([root]); const queue: Array<[string, string[]]> = [[root, specifiers]]; + for (const peer of hbsPeers(root)) seen.add(peer); while (queue.length > 0) { const [from, specs] = queue.pop()!; for (const spec of specs) { const dep = resolveWith(spec, from, paths); if (!dep || seen.has(dep)) continue; seen.add(dep); + for (const peer of hbsPeers(dep)) seen.add(peer); const record = fileRecord(dep); if (record) queue.push([dep, record.imports()]); } @@ -398,16 +444,45 @@ export function dependencyClosure(file: string, contents: string, tsconfigPath: // --- project-wide inputs ------------------------------------------------------- +// The directory the project's inputs are found under: the tsconfig's, or +// without one the nearest with a package.json. +function projectRootFor(file: string, tsconfigPath: string | null): string { + if (tsconfigPath) return path.dirname(tsconfigPath); + let dir = path.dirname(path.resolve(file)); + while (dir !== path.dirname(dir) && !fs.existsSync(path.join(dir, 'package.json'))) dir = path.dirname(dir); + return dir; +} + +// The nearest lockfile at or above the project root (a workspace keeps +// one at the repository root). +function lockfileFor(projectRoot: string): string | null { + let dir = projectRoot; + for (;;) { + for (const name of LOCKFILES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + if (dir === path.dirname(dir)) return null; + dir = path.dirname(dir); + } +} + // Files whose declarations reach every file without an import: `.d.ts` -// files, and source files with `declare module` / `declare global` -// (registry augmentations). The list is found once per process; the -// content of each file is re-checked on every call. -const projectInputFilesByRoot = new Map(); +// files, source files with `declare module` / `declare global` (registry +// augmentations), and the lockfile. A nested directory with its own +// package.json is another project and is not walked. The walk is +// re-validated on the mtimes of the directories it visited. +interface ProjectInputs { + files: string[]; + dirs: Array<[string, number]>; +} +const projectInputsByRoot = new Map(); function projectInputFiles(projectRoot: string): string[] { - let files = projectInputFilesByRoot.get(projectRoot); - if (files) return files; - files = []; + const cached = projectInputsByRoot.get(projectRoot); + if (cached && (staticFileSystem || cached.dirs.every(([dir, mtime]) => mtimeOf(dir) === mtime))) return cached.files; + const files: string[] = []; + const dirs: Array<[string, number]> = []; const walk = (dir: string): void => { let entries: fs.Dirent[]; try { @@ -415,15 +490,18 @@ function projectInputFiles(projectRoot: string): string[] { } catch { return; } + dirs.push([dir, mtimeOf(dir)]); + if (dir !== projectRoot && entries.some((entry) => entry.name === 'package.json')) return; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (!SKIPPED_DIRS.has(entry.name) && !entry.name.startsWith('.')) walk(full); } else if (entry.name.endsWith('.d.ts')) { - files!.push(full); + files.push(full); } else if (/\.(?:ts|tsx|gts|gjs)$/.test(entry.name)) { try { - if (GLOBAL_DECLARATION.test(fs.readFileSync(full, 'utf8'))) files!.push(full); + const text = fs.readFileSync(full, 'utf8'); + if (text.includes('declare ') && GLOBAL_DECLARATION.test(text)) files.push(full); } catch { // unreadable — skip } @@ -431,9 +509,10 @@ function projectInputFiles(projectRoot: string): string[] { } }; walk(projectRoot); - files.push(...LOCKFILES.map((name) => path.join(projectRoot, name))); + const lockfile = lockfileFor(projectRoot); + if (lockfile) files.push(lockfile); files.sort(); - projectInputFilesByRoot.set(projectRoot, files); + projectInputsByRoot.set(projectRoot, { files, dirs }); return files; } @@ -454,12 +533,11 @@ function hashFiles(hash: crypto.Hash, projectRoot: string, files: string[]): voi // hashed — an edit to any file the registry reaches invalidates every // template. Both shas are computed once per run under a static file // system. -const inputsShaByTsconfig = new Map(); +const inputsShaByRoot = new Map(); -function projectInputsSha(tsconfigPath: string, withImports: boolean): string { - const memo = inputsShaByTsconfig.get(tsconfigPath); +function projectInputsSha(projectRoot: string, tsconfigPath: string | null, withImports: boolean): string { + const memo = inputsShaByRoot.get(projectRoot); if (memo && staticFileSystem) return withImports ? memo.closure : memo.own; - const projectRoot = path.dirname(tsconfigPath); const inputs = projectInputFiles(projectRoot); const closure = new Set(inputs); for (const input of inputs) { @@ -473,25 +551,32 @@ function projectInputsSha(tsconfigPath: string, withImports: boolean): string { return hash.digest('hex'); }; const shas = { own: shaOf(inputs), closure: shaOf([...closure].sort()) }; - inputsShaByTsconfig.set(tsconfigPath, shas); + inputsShaByRoot.set(projectRoot, shas); return withImports ? shas.closure : shas.own; } +// Under a static file system a file's sha is computed once per content. +const shaByFile = new Map(); + /** - * Sha over everything `file`'s type information can depend on besides its - * own content: its import closure and the project-wide inputs (ambient + * Sha over everything `file`'s result can depend on besides its own + * content: its import closure and the project-wide inputs (ambient * declarations, module augmentations, the lockfile) — for a `.hbs` * template, the inputs with everything they import. */ export function dependencySha(file: string, contents: string, tsconfigPath: string | null): string { - if (!tsconfigPath) return 'no-tsconfig'; + const contentSha = sha256(contents); + const memo = staticFileSystem ? shaByFile.get(file) : undefined; + if (memo && memo.contentSha === contentSha) return memo.sha; insideSha = true; try { - const projectRoot = path.dirname(tsconfigPath); + const projectRoot = projectRootFor(file, tsconfigPath); const hash = crypto.createHash('sha256'); hashFiles(hash, projectRoot, dependencyClosure(file, contents, tsconfigPath)); - hash.update(projectInputsSha(tsconfigPath, file.endsWith('.hbs'))); - return hash.digest('hex'); + hash.update(projectInputsSha(projectRoot, tsconfigPath, file.endsWith('.hbs'))); + const sha = hash.digest('hex'); + shaByFile.set(file, { contentSha, sha }); + return sha; } finally { insideSha = false; revalidatedInsideSha = false; diff --git a/run.ts b/run.ts index 5c95cc5..2942352 100644 --- a/run.ts +++ b/run.ts @@ -9,6 +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 { __glintUnavailable } from './transform.js'; import { backendKindFor, findTsconfig } from './lib/backend/index.js'; import { readReportCache, reportCacheKey, writeReportCache } from './lib/cache.js'; import type { CachedReport } from './lib/cache.js'; @@ -137,7 +138,7 @@ function printUsage(): void { '\n' + ' Environment:\n' + ' HVE_GLINT=0 disable Glint type extraction (same as --no-glint; default: on).\n' + - ' HVE_NO_CACHE=1 bypass the on-disk Glint extraction cache.\n' + + ' HVE_NO_CACHE=1 bypass the on-disk caches (Glint extraction, transform output, reports).\n' + ' HVE_DEBUG=1 on Glint preload, print per-file skip reasons (non-gts/gjs, read error, rewrite empty/error).\n' + ' HVE_MAX_CONDITIONAL_BRANCHES=N cap multipass enumeration at N conditional branches per template\n' + ' (default 10; up to 2^N combinations, often fewer thanks to tree-aware enumeration).\n' + @@ -233,9 +234,33 @@ function printUsage(): void { // target list are filtered out — Glint doesn't apply to classic // templates, and counting them would inflate the "analyzing N // templates" header with files Glint will never touch. + // A file's report depends on its content, the configuration and the + // plugin; unchanged files replay their last report instead of being + // validated again. Keys first, so the Glint preload covers misses only. + const htmlValidateVersion = (createRequire(import.meta.url)('html-validate/package.json') as { version: string }).version; + const reportKeys = new Map(); + const cachedReports = new Map>(); + for (const file of files) { + try { + const tsconfigPath = findTsconfig(file); + const key = reportCacheKey( + file, + fs.readFileSync(file, 'utf8'), + userConfig, + htmlValidateVersion, + tsconfigPath, + tsconfigPath ? backendKindFor(tsconfigPath) : 'none', + ); + reportKeys.set(file, key); + const cached = readReportCache(file, key); + if (cached) cachedReports.set(file, cached); + } catch { + // unreadable: let validateFile report it + } + } const glintFiles = process.env['HVE_GLINT'] !== '0' - ? files.filter((f) => f.endsWith('.gts') || f.endsWith('.gjs')) + ? files.filter((f) => (f.endsWith('.gts') || f.endsWith('.gjs')) && !cachedReports.has(f)) : []; if (glintFiles.length > 1) { const isTTY = Boolean(process.stderr.isTTY); @@ -334,10 +359,6 @@ 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++; @@ -356,21 +377,8 @@ function printUsage(): void { } }; for (const file of files) { - let key: string | null = null; - try { - const tsconfigPath = findTsconfig(file); - key = reportCacheKey( - file, - fs.readFileSync(file, 'utf8'), - userConfig, - htmlValidateVersion, - tsconfigPath, - tsconfigPath ? backendKindFor(tsconfigPath) : 'none', - ); - } catch { - // unreadable: let validateFile report it - } - const cachedReport = key ? readReportCache(file, key) : null; + const key = reportKeys.get(file) ?? null; + const cachedReport = cachedReports.get(file); if (cachedReport) { recordReport(cachedReport); tickValidation(); @@ -388,8 +396,11 @@ function printUsage(): void { tickValidation(); continue; } + // A report computed after a Glint extraction threw is not stored; + // the next run retries. + const cacheable = key !== null && !__glintUnavailable.has(path.resolve(file)); if (report.valid) { - if (key) writeReportCache(file, key, { valid: true, errorCount: 0, warningCount: 0, results: [] }); + if (cacheable) writeReportCache(file, key, { valid: true, errorCount: 0, warningCount: 0, results: [] }); valid++; tickValidation(); continue; @@ -401,7 +412,7 @@ 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) { + if (cacheable) { writeReportCache(file, key, { valid: deduped.valid, errorCount: deduped.errorCount, diff --git a/test/cache.test.ts b/test/cache.test.ts index 4af37cd..9b55ace 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -209,10 +209,14 @@ describe('report cache', () => { 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(file, 'v1', { extends: ['html-validate:recommended'] }, '11.0.0', otherTsconfig, 'ts6'))).toBeNull(); + // an edit to the tsconfig itself changes the key + const original = fs.readFileSync(tsconfigPath, 'utf8'); + fs.writeFileSync(tsconfigPath, '{"compilerOptions":{"target":"es2020"}}'); + expect(readReportCache(file, key('v1'))).toBeNull(); + fs.writeFileSync(tsconfigPath, original); process.env['HVE_MAX_CONDITIONAL_BRANCHES'] = '2'; try { expect(readReportCache(file, key('v1'))).toBeNull(); diff --git a/test/deps.test.ts b/test/deps.test.ts index 227ec61..c401618 100644 --- a/test/deps.test.ts +++ b/test/deps.test.ts @@ -11,7 +11,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { dependencyClosure, dependencySha, importSpecifiers, parseJsonc, resolveProjectImport } from '../lib/deps.js'; +import { dependencyClosure, dependencySha, importSpecifiers, parseJsonc, resolveProjectImport, tsconfigChainSha } from '../lib/deps.js'; import { readCache, reportCacheKey, transformCacheKey, writeCache } from '../lib/cache.js'; const FIXTURES = fileURLToPath(new URL('./deps-fixtures', import.meta.url)); @@ -79,10 +79,6 @@ describe('dependencyClosure', () => { expect(dependencyClosure(component('cycle-a.gts'), read('cycle-a.gts'), tsconfig)).toEqual([component('cycle-b.gts')]); expect(dependencyClosure(component('cycle-b.gts'), read('cycle-b.gts'), tsconfig)).toEqual([component('cycle-a.gts')]); }); - - it('is empty without a tsconfig-relative project', () => { - expect(dependencySha(component('leaf.gts'), read('leaf.gts'), null)).toBe('no-tsconfig'); - }); }); describe('dependencySha: invalidation', () => { @@ -163,10 +159,11 @@ describe('cache keys include the closure', () => { }); describe('tsconfig reading', () => { - it('parses strings that end in a backslash and keeps comments inside strings', () => { - expect(parseJsonc('{ "outDir": "c:\\\\out\\\\", // c\n "url": "http://x/*", /* b */ "n": [1, 2,], }')).toEqual({ + it('parses strings that end in a backslash and keeps comments and commas inside strings', () => { + expect(parseJsonc('{ "outDir": "c:\\\\out\\\\", // c\n "url": "http://x/*", /* b */ "glob": "src/{a,}", "n": [1, 2,], }')).toEqual({ outDir: 'c:\\out\\', url: 'http://x/*', + glob: 'src/{a,}', n: [1, 2], }); }); @@ -292,16 +289,85 @@ describe('long-lived process', () => { }); describe('without Glint', () => { - it('leaves the closure out of the transform and report keys', () => { + it('keeps the closure in the transform and report keys: the resolver still reads imported templates', () => { const file = component('mid.gts'); const contents = read('mid.gts'); process.env['HVE_GLINT'] = '0'; try { const before = [transformCacheKey(file, contents, tsconfig, 'ts6'), reportCacheKey(file, contents, {}, '11.0.0', tsconfig, 'ts6')]; edit(component('leaf.gts'), '\n'); - expect([transformCacheKey(file, contents, tsconfig, 'ts6'), reportCacheKey(file, contents, {}, '11.0.0', tsconfig, 'ts6')]).toEqual(before); + const after = [transformCacheKey(file, contents, tsconfig, 'ts6'), reportCacheKey(file, contents, {}, '11.0.0', tsconfig, 'ts6')]; + expect(after[0]).not.toBe(before[0]); + expect(after[1]).not.toBe(before[1]); } finally { delete process.env['HVE_GLINT']; } }); }); + +describe('files the resolver reads without an import', () => { + it('includes a module\'s co-located .hbs template and templates/components peer', () => { + fs.writeFileSync(component('classic.ts'), 'export default class {}'); + fs.writeFileSync(component('classic.hbs'), '
  • {{yield}}
  • '); + fs.mkdirSync(path.join(root, 'app', 'templates', 'components'), { recursive: true }); + fs.writeFileSync(component('peer.ts'), 'export default class {}'); + fs.writeFileSync(path.join(root, 'app', 'templates', 'components', 'peer.hbs'), '
  • {{yield}}
  • '); + fs.writeFileSync(component('root.gts'), "import Classic from './classic';\nimport Peer from './peer';\n"); + expect(dependencyClosure(component('root.gts'), read('root.gts'), tsconfig)).toEqual( + [component('classic.hbs'), component('classic.ts'), component('peer.ts'), path.join(root, 'app', 'templates', 'components', 'peer.hbs')].sort(), + ); + const before = shaOf('root.gts'); + edit(component('classic.hbs'), '\n'); + expect(shaOf('root.gts')).not.toBe(before); + }); + + it('follows /// directives', () => { + fs.writeFileSync(component('root.gts'), '/// \n'); + expect(importSpecifiers(read('root.gts'))).toEqual(['../../types/global.d.ts']); + expect(dependencyClosure(component('root.gts'), read('root.gts'), tsconfig)).toEqual([path.join(root, 'types', 'global.d.ts')]); + }); +}); + +describe('project layout', () => { + it('finds the lockfile above the tsconfig directory (workspace root)', () => { + const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hve-deps-ws-'))); + const app = path.join(workspace, 'packages', 'app'); + fs.cpSync(root, app, { recursive: true }); + fs.rmSync(path.join(app, 'pnpm-lock.yaml')); + fs.writeFileSync(path.join(workspace, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + const sha = () => dependencySha(path.join(app, 'app/components/unrelated.gts'), '', path.join(app, 'tsconfig.json')); + try { + const before = sha(); + edit(path.join(workspace, 'pnpm-lock.yaml'), '\n'); + expect(sha()).not.toBe(before); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } + }); + + it('does not walk a nested package for project-wide inputs', () => { + const before = shaOf('unrelated.gts'); + fs.mkdirSync(path.join(root, 'packages', 'other'), { recursive: true }); + fs.writeFileSync(path.join(root, 'packages', 'other', 'package.json'), '{}'); + fs.writeFileSync(path.join(root, 'packages', 'other', 'global.d.ts'), 'declare const other: 1;'); + expect(shaOf('unrelated.gts')).toBe(before); + fs.writeFileSync(path.join(root, 'types', 'more.d.ts'), 'declare const more: 1;'); + expect(shaOf('unrelated.gts')).not.toBe(before); + }); + + it('resolves relative imports without a tsconfig, and nothing else', () => { + fs.rmSync(tsconfig); + fs.rmSync(path.join(root, 'tsconfig.base.json')); + expect(dependencyClosure(component('mid.gts'), read('mid.gts'), null)).toEqual([component('leaf.gts')]); + expect(resolveProjectImport('app/components/mid', component('root.gts'), null)).toBeNull(); + const before = dependencySha(component('mid.gts'), read('mid.gts'), null); + edit(component('leaf.gts'), '\n'); + expect(dependencySha(component('mid.gts'), read('mid.gts'), null)).not.toBe(before); + }); + + it('hashes the whole extends chain into the tsconfig sha', () => { + const before = tsconfigChainSha(tsconfig); + edit(path.join(root, 'tsconfig.base.json'), '\n'); + expect(tsconfigChainSha(tsconfig)).not.toBe(before); + }); +}); diff --git a/transform.ts b/transform.ts index ca27ca5..162a594 100644 --- a/transform.ts +++ b/transform.ts @@ -20,6 +20,7 @@ import { buildResolutionMaps } from './lib/resolver/build-maps.js'; import { isDynamicValuePlaceholder } from './lib/dynamic-value.js'; import { extractAttrTypeMap } from './lib/glint.js'; import { backendKindFor, findTsconfig } from './lib/backend/index.js'; +import path from 'node:path'; import { readTransformCache, transformCacheKey, writeTransformCache } from './lib/cache.js'; import type { CachedPass, CachedTemplate } from './lib/cache.js'; import { extractStringScope } from './lib/scope.js'; @@ -101,6 +102,13 @@ const preprocessor = new Preprocessor(); // embedder that runs concurrent `validateFile` calls would need to // rework this. export const __multipassBranchedRanges = new Map>(); +/** + * Files (absolute paths) whose last Glint extraction threw. A missing + * backend is a stable state the key already covers (backend kind, + * tsconfig chain, lockfile); a throw is not, so the result is not cached + * at any level and the next run retries. + */ +export const __glintUnavailable = new Set(); // Build an inline `` directive to prepend // to a multipass branched Source. The only rule passed in today is @@ -307,10 +315,11 @@ function* transformGlimmer(source: Source): Generator { // content-tag parse, no Glint, no blanking. const tsconfigPath = findTsconfig(filename); const key = transformCacheKey(filename, data, tsconfigPath, tsconfigPath ? backendKindFor(tsconfigPath) : 'none'); + __glintUnavailable.delete(path.resolve(filename)); let templates = readTransformCache(filename, key); if (!templates) { templates = computeTemplates(filename, data); - if (templates) writeTransformCache(filename, key, templates); + if (templates && !__glintUnavailable.has(path.resolve(filename))) writeTransformCache(filename, key, templates); } if (!templates) return; @@ -392,6 +401,7 @@ function computeTemplates(filename: string, data: string): CachedTemplate[] | nu glintComponentAttrMap = result.componentAttrMap; } } catch (err) { + __glintUnavailable.add(path.resolve(filename)); process.stderr.write( `[html-validate-ember] glint type extraction failed for ${filename}: ${ err instanceof Error ? err.message : String(err) From acc48ae1de0a14426f959456fce7895c1b3916ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 13:40:24 +0200 Subject: [PATCH 2/3] parseJsonc: drop trailing commas before comments; hbsPeers: accept Windows separators Copilot review on #58: the string-safe JSONC scanner only skipped whitespace when looking for the closing bracket, so `{ "a": 1, // note\n }` kept the comma and JSON.parse failed after comment removal. The templates/components peer regex was POSIX-only, dropping the classic-layout peer from the closure on Windows. --- lib/deps.ts | 16 ++++++++++++++-- test/deps.test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/deps.ts b/lib/deps.ts index 773183a..c47e57d 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -166,8 +166,20 @@ export function parseJsonc(text: string): unknown { const end = text.indexOf('*/', i + 2); i = end === -1 ? text.length : end + 2; } else if (ch === ',') { + // Trailing if only whitespace and comments separate it from the closing bracket let j = i + 1; - while (j < text.length && /\s/.test(text[j]!)) j++; + for (;;) { + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] === '/' && text[j + 1] === '/') { + j = text.indexOf('\n', j); + if (j === -1) j = text.length; + } else if (text[j] === '/' && text[j + 1] === '*') { + const end = text.indexOf('*/', j + 2); + j = end === -1 ? text.length : end + 2; + } else { + break; + } + } if (text[j] === '}' || text[j] === ']') { i++; } else { @@ -403,7 +415,7 @@ function resolveUncached(spec: string, fromFile: string, { baseUrl, pathsBase, p // `templates/components/`; the resolver reads it, so it is part of the // module for the cache. function hbsPeers(file: string): string[] { - const m = /^(.*)\/components\/([^/]+)\.(?:ts|js)$/.exec(file); + const m = /^(.*)[\\/]components[\\/]([^\\/]+)\.(?:ts|js)$/.exec(file); const peers = [file.replace(/\.(?:ts|js)$/, '.hbs')]; if (m) peers.push(path.join(m[1]!, 'templates', 'components', `${m[2]!}.hbs`)); return peers.filter((peer) => peer !== file && fileRecord(peer) !== null); diff --git a/test/deps.test.ts b/test/deps.test.ts index c401618..17fdaf5 100644 --- a/test/deps.test.ts +++ b/test/deps.test.ts @@ -168,6 +168,12 @@ describe('tsconfig reading', () => { }); }); + it('drops a trailing comma that is separated from the closing bracket by comments', () => { + expect(parseJsonc('{ "a": 1, // note\n }')).toEqual({ a: 1 }); + expect(parseJsonc('[1, /* x */ // y\n ]')).toEqual([1]); + expect(parseJsonc('{ "a": 1, /* not trailing */ "b": 2 }')).toEqual({ a: 1, b: 2 }); + }); + it('keeps resolving when a tsconfig has a malformed paths entry or cannot be parsed', () => { fs.writeFileSync(tsconfig, '{ "compilerOptions": { "baseUrl": ".", "paths": { "app/*": "./app/*", "*": ["./types/*"] } } }'); const from = component('root.gts'); From 7efe43051b5463215f23719fe6412c64bb0d0ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 13:57:48 +0200 Subject: [PATCH 3/3] backendKindFor: resolve the TS7 package instead of loading it The cache-key component loaded typescript-7's sync and ast modules (~30 ms) on every process, including replays that never need a backend. Whether the package loads is decided by its version and the Node version, so both are in the key and the package is only resolved. --- lib/backend/index.ts | 12 +++++++----- test/glint.test.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/backend/index.ts b/lib/backend/index.ts index 5635ce7..181c79f 100644 --- a/lib/backend/index.ts +++ b/lib/backend/index.ts @@ -11,7 +11,7 @@ import { createRequire } from 'node:module'; import type * as TS from 'typescript'; import { createTs6Backend, loadTs6Deps, ts6Syntax } from './ts6.js'; -import { createTsgoBackend, loadTsgo } from './tsgo.js'; +import { createTsgoBackend, loadTsgo, resolveTsgoPackage } from './tsgo.js'; import type { TsSyntax, TypeBackend } from './types.js'; export type { @@ -70,15 +70,17 @@ function declaresContentMappers(tsconfigPath: string): boolean { /** * Which backend `backendFor` would pick, as a cache-key component: the * forced kind, or tsgo (with its package and version) when the tsconfig - * declares `contentMappers` and a TypeScript 7 package loads. Same - * decision as `selectBackend`, without opening a project. + * declares `contentMappers` and a TypeScript 7 package resolves. Whether + * that package then loads depends on the Node version (`require()` of its + * ESM API needs 22.12+), so Node is part of the component too; the + * package is not loaded here — a cached replay never needs it. */ export function backendKindFor(tsconfigPath: string): string { const forced = process.env['HVE_TS_BACKEND']; if (forced === 'ts6') return 'ts6'; if (forced === 'tsgo' || declaresContentMappers(tsconfigPath)) { - const mods = loadTsgo(path.dirname(tsconfigPath)); - if (mods) return `tsgo:${mods.packageName}@${mods.version}`; + const pkg = resolveTsgoPackage(path.dirname(tsconfigPath)); + if (pkg) return `tsgo:${pkg.name}@${pkg.version}:node${process.versions.node}`; if (forced === 'tsgo') return 'none'; } return 'ts6'; diff --git a/test/glint.test.ts b/test/glint.test.ts index f60e324..d92a846 100644 --- a/test/glint.test.ts +++ b/test/glint.test.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import { extractAttrTypeMap } from '../lib/glint.js'; -import { backendFor } from '../lib/backend/index.js'; +import { backendFor, backendKindFor, findTsconfig } from '../lib/backend/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, 'glint-fixtures'); @@ -756,6 +756,13 @@ describe('type backend selection', () => { const { filename } = readFixture('inline-typed-popover.gts'); expect(backendFor(filename)?.kind).toBe(process.env['HVE_TS_BACKEND'] ?? 'tsgo'); }); + + it('keys a tsgo backend on the package and the Node version, which decide whether it loads', () => { + const { filename } = readFixture('inline-typed-popover.gts'); + const kind = backendKindFor(findTsconfig(filename)!); + if (kind.startsWith('tsgo:')) expect(kind).toMatch(new RegExp(`^tsgo:[^@]+@[^:]+:node${process.versions.node.replaceAll('.', '\\.')}$`)); + else expect(kind).toBe(process.env['HVE_TS_BACKEND'] ?? 'ts6'); + }); }); describe('attribute mustache sites', () => {