From e2a2141bb6a3367dd81320c9087fd7086151fa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 11:46:39 +0200 Subject: [PATCH 1/5] Cache keys include the import closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry's key now covers the content of every project file the file imports, transitively, plus the project's ambient `.d.ts` files and its lockfile — so editing an imported component invalidates its consumers. Same shape as tsc's incremental `referencedMap`, keyed on content rather than on the exported signature. `lib/deps.ts` finds specifiers by text scan and resolves them through relative paths, tsconfig `paths`/`baseUrl` (following `extends`), `.js` → `.ts`, `.gts`/`.gjs`-first extension probing and directory `index` files; packages are external. Resolution is memoised per process; file content per mtime and size. Applies to the Glint, transform and report caches. Fixture project and scenario tests in test/deps-fixtures and test/deps.test.ts. Cowritten by Claude --- README.md | 2 +- lib/cache.ts | 11 +- lib/deps.ts | 318 ++++++++++++++++++ run.ts | 1 + test/cache.test.ts | 14 +- test/deps-fixtures/app/components/cycle-a.gts | 2 + test/deps-fixtures/app/components/cycle-b.gts | 2 + .../deps-fixtures/app/components/dir/index.ts | 1 + test/deps-fixtures/app/components/leaf-two.ts | 1 + test/deps-fixtures/app/components/leaf.gts | 1 + test/deps-fixtures/app/components/mid.gts | 2 + test/deps-fixtures/app/components/root.gts | 11 + .../app/components/unrelated.gts | 1 + test/deps-fixtures/package.json | 1 + test/deps-fixtures/pnpm-lock.yaml | 1 + test/deps-fixtures/tsconfig.base.json | 1 + test/deps-fixtures/tsconfig.json | 10 + test/deps-fixtures/types/global.d.ts | 1 + test/deps-fixtures/types/operations.d.ts | 1 + test/deps.test.ts | 163 +++++++++ transform.ts | 2 +- 21 files changed, 536 insertions(+), 11 deletions(-) create mode 100644 lib/deps.ts create mode 100644 test/deps-fixtures/app/components/cycle-a.gts create mode 100644 test/deps-fixtures/app/components/cycle-b.gts create mode 100644 test/deps-fixtures/app/components/dir/index.ts create mode 100644 test/deps-fixtures/app/components/leaf-two.ts create mode 100644 test/deps-fixtures/app/components/leaf.gts create mode 100644 test/deps-fixtures/app/components/mid.gts create mode 100644 test/deps-fixtures/app/components/root.gts create mode 100644 test/deps-fixtures/app/components/unrelated.gts create mode 100644 test/deps-fixtures/package.json create mode 100644 test/deps-fixtures/pnpm-lock.yaml create mode 100644 test/deps-fixtures/tsconfig.base.json create mode 100644 test/deps-fixtures/tsconfig.json create mode 100644 test/deps-fixtures/types/global.d.ts create mode 100644 test/deps-fixtures/types/operations.d.ts create mode 100644 test/deps.test.ts diff --git a/README.md b/README.md index e563072..6d5d2ce 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Glint adds per-file overhead (TS program build, module rewrite, TypeChecker call ### Caching -Glint results are content-addressed and cached on disk under `node_modules/.cache/html-validate-ember/glint/`. Set `HVE_NO_CACHE=1` to bypass the cache. +Glint results, the transform output and (for `validate-gts`) the report of each file are cached on disk under `node_modules/.cache/html-validate-ember/`. An entry is keyed on the file's content plus the content of every project file it imports, transitively (through relative paths and tsconfig `paths`), the project's ambient `.d.ts` files, the lockfile, the tsconfig and the plugin — so editing an imported component invalidates its consumers. Set `HVE_NO_CACHE=1` to bypass the caches. ## Silencing rules diff --git a/lib/cache.ts b/lib/cache.ts index 3bc2b37..b8e1b63 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -36,6 +36,7 @@ import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import type { ComponentAttrs } from './builtin-components.js'; +import { dependencySha } 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 @@ -130,6 +131,7 @@ interface CacheEntry { pluginSourceSha: string; tsconfigSha: string; fileSha: string; + dependencySha: string; attrTypeMap: Array<[string, AttrTypeInfo]>; componentTagMap: Array<[string, string]>; componentAttrMap: Array<[string, ComponentAttrs]>; @@ -220,7 +222,8 @@ export function readCache( parsed.backend !== backend || parsed.pluginSourceSha !== PLUGIN_SOURCE_SHA || parsed.tsconfigSha !== tsconfigSha || - parsed.fileSha !== fileSha + parsed.fileSha !== fileSha || + parsed.dependencySha !== dependencySha(filename, contents, tsconfigPath) ) { return null; } @@ -252,6 +255,7 @@ export function writeCache( pluginSourceSha: PLUGIN_SOURCE_SHA, tsconfigSha: getTsconfigSha(tsconfigPath), fileSha: sha256(contents), + dependencySha: dependencySha(filename, contents, tsconfigPath), attrTypeMap: serializeMap(result.attrTypeMap), componentTagMap: serializeMap(result.componentTagMap), componentAttrMap: serializeMap(result.componentAttrMap), @@ -291,10 +295,11 @@ interface TransformCacheEntry { } /** Everything the transform's output depends on besides the plugin itself. */ -export function transformCacheKey(data: string, tsconfigPath: string | null, backendKind: string): string { +export function transformCacheKey(filename: string, data: string, tsconfigPath: string | null, backendKind: string): string { return sha256( [ data, + dependencySha(filename, data, tsconfigPath), tsconfigPath ? getTsconfigSha(tsconfigPath) : 'no-tsconfig', backendKind, process.env['HVE_GLINT'] ?? '', @@ -362,6 +367,7 @@ interface ReportCacheEntry extends CachedReport { } export function reportCacheKey( + filename: string, contents: string, config: unknown, htmlValidateVersion: string, @@ -371,6 +377,7 @@ export function reportCacheKey( return sha256( [ contents, + dependencySha(filename, contents, tsconfigPath), JSON.stringify(config ?? null), htmlValidateVersion, tsconfigPath ? getTsconfigSha(tsconfigPath) : 'no-tsconfig', diff --git a/lib/deps.ts b/lib/deps.ts new file mode 100644 index 0000000..44bbdc2 --- /dev/null +++ b/lib/deps.ts @@ -0,0 +1,318 @@ +// 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 `.d.ts`, installed +// packages). The cache keys include `dependencySha`, so a change anywhere +// upstream misses for every file downstream — the same shape as tsc's +// incremental `referencedMap`, but keyed on file content rather than on +// the exported signature, so it is stricter than tsc, never looser. +// +// Imports are found by scanning the text for specifiers, not by parsing; +// a specifier that does not resolve to a project file is external (a +// package, covered by the lockfile sha) and ignored. Resolution follows +// relative paths, tsconfig `paths` and `baseUrl` (through relative and +// package `extends`), TypeScript's `.js` → `.ts` rewrite, extension +// probing with `.gts`/`.gjs` first, and directory `index` files. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { createRequire } from 'node:module'; + +const EXTENSIONS = ['.gts', '.gjs', '.ts', '.tsx', '.d.ts', '.js', '.mjs', '.cjs', '.jsx']; +const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'tmp']); +const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lock', 'bun.lockb']; + +function sha256(input: string | Buffer): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +// --- file content, memoised on mtime and size ------------------------------- + +interface FileRecord { + mtimeMs: number; + size: number; + sha: string; + imports: string[]; + /** Project files the imports resolve to; filled on first use. */ + edges?: string[]; +} +const fileRecords = new Map(); + +function fileRecord(file: string): FileRecord | null { + let stat: fs.Stats; + try { + stat = fs.statSync(file); + } catch { + fileRecords.set(file, null); + return 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; + try { + contents = fs.readFileSync(file, 'utf8'); + } catch { + return null; + } + const record = { mtimeMs: stat.mtimeMs, size: stat.size, sha: sha256(contents), imports: importSpecifiers(contents) }; + fileRecords.set(file, record); + return record; +} + +/** `from '...'`, `import '...'` and `import('...')` specifiers, 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); + } + return found; +} + +// --- tsconfig `paths` --------------------------------------------------------- + +interface ProjectPaths { + /** Absolute directory non-relative specifiers resolve against. */ + baseUrl: string | null; + /** Pattern → absolute target patterns, child config first. */ + paths: Array<[string, string[]]>; +} +const projectPathsByTsconfig = new Map(); + +// tsconfig.json is JSON with comments and trailing commas. +function parseJsonc(text: string): unknown { + let out = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]!; + if (ch === '"') { + let j = i + 1; + while (j < text.length && (text[j] !== '"' || text[j - 1] === '\\')) j++; + out += text.slice(i, j + 1); + i = j + 1; + } else if (ch === '/' && text[i + 1] === '/') { + i = text.indexOf('\n', i); + if (i === -1) i = text.length; + } else if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + i = end === -1 ? text.length : end + 2; + } else { + out += ch; + i++; + } + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')); +} + +interface TsconfigShape { + extends?: string | string[]; + compilerOptions?: { baseUrl?: string; paths?: Record }; +} + +function readTsconfigChain(tsconfigPath: string, seen = new Set()): ProjectPaths { + const result: ProjectPaths = { baseUrl: null, paths: [] }; + if (seen.has(tsconfigPath)) return result; + seen.add(tsconfigPath); + let config: TsconfigShape; + try { + config = parseJsonc(fs.readFileSync(tsconfigPath, 'utf8')) as TsconfigShape; + } catch { + return result; + } + const dir = path.dirname(tsconfigPath); + const options = config.compilerOptions ?? {}; + if (options.baseUrl) result.baseUrl = path.resolve(dir, options.baseUrl); + // `paths` without `baseUrl` resolve relative to the tsconfig that declares them. + const pathsBase = result.baseUrl ?? dir; + for (const [pattern, targets] of Object.entries(options.paths ?? {})) { + result.paths.push([pattern, targets.map((t) => path.resolve(pathsBase, t))]); + } + const parents = Array.isArray(config.extends) ? config.extends : config.extends ? [config.extends] : []; + for (const parent of parents) { + const parentPath = resolveExtends(parent, dir); + if (!parentPath) continue; + const inherited = readTsconfigChain(parentPath, seen); + result.baseUrl ??= inherited.baseUrl; + result.paths.push(...inherited.paths); + } + return result; +} + +function resolveExtends(spec: string, fromDir: string): string | null { + if (spec.startsWith('.') || path.isAbsolute(spec)) { + const abs = path.resolve(fromDir, spec); + return fs.existsSync(abs) ? abs : fs.existsSync(`${abs}.json`) ? `${abs}.json` : null; + } + try { + return createRequire(path.join(fromDir, 'package.json')).resolve(spec); + } catch { + return null; + } +} + +function projectPaths(tsconfigPath: string): ProjectPaths { + let cached = projectPathsByTsconfig.get(tsconfigPath); + if (!cached) { + cached = readTsconfigChain(tsconfigPath); + projectPathsByTsconfig.set(tsconfigPath, cached); + } + return cached; +} + +// --- module resolution ----------------------------------------------------------- + +function probeFile(candidate: string): string | null { + const stem = candidate.replace(/\.(?:js|mjs|cjs|jsx)$/, ''); + const attempts = [candidate, ...EXTENSIONS.map((ext) => stem + ext), ...EXTENSIONS.map((ext) => path.join(candidate, `index${ext}`))]; + for (const attempt of attempts) { + try { + if (fs.statSync(attempt).isFile()) return attempt; + } catch { + // next + } + } + return null; +} + +function matchPattern(pattern: string, spec: string): string | null { + const star = pattern.indexOf('*'); + if (star === -1) return pattern === spec ? '' : null; + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (spec.length < prefix.length + suffix.length || !spec.startsWith(prefix) || !spec.endsWith(suffix)) return null; + return spec.slice(prefix.length, spec.length - suffix.length); +} + +// Resolution is memoised per (importing directory, specifier) for the life +// of the process: the file system is probed once per distinct import. A +// file created later, that an earlier unresolved specifier would now +// reach, is seen after a restart. +const resolutionByKey = new Map(); + +/** The project file `spec` refers to from `fromFile`, or null when it is a package or unresolved. */ +export function resolveImport(spec: string, fromFile: string, tsconfigPath: string): string | null { + const memoKey = `${tsconfigPath}\0${path.dirname(fromFile)}\0${spec}`; + const memo = resolutionByKey.get(memoKey); + if (memo !== undefined) return memo; + const resolved = resolveImportUncached(spec, fromFile, tsconfigPath); + resolutionByKey.set(memoKey, resolved); + return resolved; +} + +function resolveImportUncached(spec: string, fromFile: string, tsconfigPath: string): string | null { + const projectRoot = path.dirname(tsconfigPath); + const candidates: string[] = []; + if (spec.startsWith('.') || path.isAbsolute(spec)) { + candidates.push(path.resolve(path.dirname(fromFile), spec)); + } else { + const { baseUrl, paths } = projectPaths(tsconfigPath); + for (const [pattern, targets] of paths) { + const wildcard = matchPattern(pattern, spec); + if (wildcard === null) continue; + candidates.push(...targets.map((t) => t.replace('*', wildcard))); + } + if (baseUrl) candidates.push(path.resolve(baseUrl, spec)); + } + for (const candidate of candidates) { + const found = probeFile(candidate); + if (found && found.startsWith(projectRoot + path.sep) && !found.includes(`${path.sep}node_modules${path.sep}`)) { + return found; + } + } + return null; +} + +// --- closure ---------------------------------------------------------------------- + +/** Project files `file` imports, transitively (absolute paths, sorted, without `file` itself). */ +export function dependencyClosure(file: string, contents: string, tsconfigPath: string): string[] { + const root = path.resolve(file); + const seen = new Set([root]); + const resolveAll = (from: string, specs: string[]) => + specs.map((spec) => resolveImport(spec, from, tsconfigPath)).filter((dep): dep is string => dep !== null); + const queue: string[][] = [resolveAll(root, importSpecifiers(contents))]; + while (queue.length > 0) { + for (const dep of queue.pop()!) { + if (seen.has(dep)) continue; + seen.add(dep); + const record = fileRecord(dep); + if (!record) continue; + record.edges ??= resolveAll(dep, record.imports); + queue.push(record.edges); + } + } + seen.delete(root); + return [...seen].sort(); +} + +// --- project-wide inputs ------------------------------------------------------- + +// Ambient declarations reach every file without an import. They and the +// lockfile are hashed once per process, like the tsconfig: a long-lived +// host sees a change to them after a restart. +const ambientFilesByRoot = new Map(); +const projectInputsShaByTsconfig = new Map(); + +function ambientDeclarationFiles(projectRoot: string): string[] { + let files = ambientFilesByRoot.get(projectRoot); + if (files) return files; + files = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name) && !entry.name.startsWith('.')) walk(path.join(dir, entry.name)); + } else if (entry.name.endsWith('.d.ts')) { + files!.push(path.join(dir, entry.name)); + } + } + }; + walk(projectRoot); + files.sort(); + ambientFilesByRoot.set(projectRoot, files); + return files; +} + +function projectInputsSha(tsconfigPath: string): string { + const memo = projectInputsShaByTsconfig.get(tsconfigPath); + if (memo !== undefined) return memo; + const projectRoot = path.dirname(tsconfigPath); + const hash = crypto.createHash('sha256'); + for (const file of [...ambientDeclarationFiles(projectRoot), ...LOCKFILES.map((name) => path.join(projectRoot, name))]) { + const record = fileRecord(file); + if (!record) continue; + hash.update(path.relative(projectRoot, file)); + hash.update(record.sha); + hash.update('\0'); + } + const sha = hash.digest('hex'); + projectInputsShaByTsconfig.set(tsconfigPath, sha); + return sha; +} + +/** + * Sha over everything `file`'s type information can depend on besides its + * own content: the content of its import closure, the project's ambient + * `.d.ts` files and its lockfile. + */ +export function dependencySha(file: string, contents: string, tsconfigPath: string | null): string { + if (!tsconfigPath) return 'no-tsconfig'; + const projectRoot = path.dirname(tsconfigPath); + const hash = crypto.createHash('sha256'); + for (const dep of dependencyClosure(file, contents, tsconfigPath)) { + const record = fileRecord(dep); + if (!record) continue; + hash.update(path.relative(projectRoot, dep)); + hash.update(record.sha); + hash.update('\0'); + } + hash.update(projectInputsSha(tsconfigPath)); + return hash.digest('hex'); +} diff --git a/run.ts b/run.ts index a606938..4cde081 100644 --- a/run.ts +++ b/run.ts @@ -358,6 +358,7 @@ function printUsage(): void { try { const tsconfigPath = findTsconfig(file); key = reportCacheKey( + file, fs.readFileSync(file, 'utf8'), userConfig, htmlValidateVersion, diff --git a/test/cache.test.ts b/test/cache.test.ts index d133c15..4af37cd 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -172,17 +172,17 @@ describe('transform cache', () => { it('round-trips the passes, including the Map- and Set-shaped arrays', () => { const file = path.join(templatesDir, 'a.gts'); - const key = transformCacheKey('', tsconfigPath, 'ts6'); + const key = transformCacheKey(file, '', tsconfigPath, 'ts6'); writeTransformCache(file, key, templates); expect(readTransformCache(file, key)).toEqual(templates); }); it('misses when the key differs', () => { const file = path.join(templatesDir, 'a.gts'); - writeTransformCache(file, transformCacheKey('v1', tsconfigPath, 'ts6'), templates); - expect(readTransformCache(file, transformCacheKey('v2', tsconfigPath, 'ts6'))).toBeNull(); - expect(readTransformCache(file, transformCacheKey('v1', tsconfigPath, 'tsgo:typescript@7.0.0'))).toBeNull(); - expect(readTransformCache(file, transformCacheKey('v1', tsconfigPath, 'ts6'))).toEqual(templates); + writeTransformCache(file, transformCacheKey(file, 'v1', tsconfigPath, 'ts6'), templates); + expect(readTransformCache(file, transformCacheKey(file, 'v2', tsconfigPath, 'ts6'))).toBeNull(); + expect(readTransformCache(file, transformCacheKey(file, 'v1', tsconfigPath, 'tsgo:typescript@7.0.0'))).toBeNull(); + expect(readTransformCache(file, transformCacheKey(file, 'v1', tsconfigPath, 'ts6'))).toEqual(templates); }); }); @@ -194,7 +194,7 @@ describe('report cache', () => { 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); + reportCacheKey(path.join(templatesDir, 'a.gts'), contents, config, version, tsconfigPath, backend); it('round-trips a report', () => { const file = path.join(templatesDir, 'a.gts'); @@ -212,7 +212,7 @@ describe('report cache', () => { 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(); + expect(readReportCache(file, reportCacheKey(file, '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(); diff --git a/test/deps-fixtures/app/components/cycle-a.gts b/test/deps-fixtures/app/components/cycle-a.gts new file mode 100644 index 0000000..b237384 --- /dev/null +++ b/test/deps-fixtures/app/components/cycle-a.gts @@ -0,0 +1,2 @@ +import B from './cycle-b'; + diff --git a/test/deps-fixtures/app/components/cycle-b.gts b/test/deps-fixtures/app/components/cycle-b.gts new file mode 100644 index 0000000..4236665 --- /dev/null +++ b/test/deps-fixtures/app/components/cycle-b.gts @@ -0,0 +1,2 @@ +import A from './cycle-a'; + diff --git a/test/deps-fixtures/app/components/dir/index.ts b/test/deps-fixtures/app/components/dir/index.ts new file mode 100644 index 0000000..0896e12 --- /dev/null +++ b/test/deps-fixtures/app/components/dir/index.ts @@ -0,0 +1 @@ +export const fromDir = () => import('../leaf'); diff --git a/test/deps-fixtures/app/components/leaf-two.ts b/test/deps-fixtures/app/components/leaf-two.ts new file mode 100644 index 0000000..1324e8f --- /dev/null +++ b/test/deps-fixtures/app/components/leaf-two.ts @@ -0,0 +1 @@ +export default ; diff --git a/test/deps-fixtures/app/components/leaf.gts b/test/deps-fixtures/app/components/leaf.gts new file mode 100644 index 0000000..a1615fa --- /dev/null +++ b/test/deps-fixtures/app/components/leaf.gts @@ -0,0 +1 @@ + diff --git a/test/deps-fixtures/app/components/mid.gts b/test/deps-fixtures/app/components/mid.gts new file mode 100644 index 0000000..1411309 --- /dev/null +++ b/test/deps-fixtures/app/components/mid.gts @@ -0,0 +1,2 @@ +import Leaf from './leaf'; + diff --git a/test/deps-fixtures/app/components/root.gts b/test/deps-fixtures/app/components/root.gts new file mode 100644 index 0000000..26d49b3 --- /dev/null +++ b/test/deps-fixtures/app/components/root.gts @@ -0,0 +1,11 @@ +import Component from '@glimmer/component'; +import Mid from 'app/components/mid'; +import LeafTwo from './leaf-two.js'; +import fromDir from './dir'; +import type { Operations } from 'operations'; +import pkg from 'some-pkg'; +import 'side-effect-only'; + +export default class Root extends Component { + +} diff --git a/test/deps-fixtures/app/components/unrelated.gts b/test/deps-fixtures/app/components/unrelated.gts new file mode 100644 index 0000000..9d9bd24 --- /dev/null +++ b/test/deps-fixtures/app/components/unrelated.gts @@ -0,0 +1 @@ + diff --git a/test/deps-fixtures/package.json b/test/deps-fixtures/package.json new file mode 100644 index 0000000..2c7d3eb --- /dev/null +++ b/test/deps-fixtures/package.json @@ -0,0 +1 @@ +{"name":"deps-fixture","private":true} diff --git a/test/deps-fixtures/pnpm-lock.yaml b/test/deps-fixtures/pnpm-lock.yaml new file mode 100644 index 0000000..b07d591 --- /dev/null +++ b/test/deps-fixtures/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: 9.0 diff --git a/test/deps-fixtures/tsconfig.base.json b/test/deps-fixtures/tsconfig.base.json new file mode 100644 index 0000000..11c1dfc --- /dev/null +++ b/test/deps-fixtures/tsconfig.base.json @@ -0,0 +1 @@ +{ "compilerOptions": { "baseUrl": ".", "strict": true } } diff --git a/test/deps-fixtures/tsconfig.json b/test/deps-fixtures/tsconfig.json new file mode 100644 index 0000000..8f73307 --- /dev/null +++ b/test/deps-fixtures/tsconfig.json @@ -0,0 +1,10 @@ +{ + // comments and trailing commas are allowed in tsconfig + "extends": "./tsconfig.base.json", + "compilerOptions": { + "paths": { + "app/*": ["./app/*"], + "*": ["./types/*"], + }, + }, +} diff --git a/test/deps-fixtures/types/global.d.ts b/test/deps-fixtures/types/global.d.ts new file mode 100644 index 0000000..f46c1ee --- /dev/null +++ b/test/deps-fixtures/types/global.d.ts @@ -0,0 +1 @@ +declare const t: (key: string) => string; diff --git a/test/deps-fixtures/types/operations.d.ts b/test/deps-fixtures/types/operations.d.ts new file mode 100644 index 0000000..1f5ada5 --- /dev/null +++ b/test/deps-fixtures/types/operations.d.ts @@ -0,0 +1 @@ +export interface Operations { id: string } diff --git a/test/deps.test.ts b/test/deps.test.ts new file mode 100644 index 0000000..e6c2a30 --- /dev/null +++ b/test/deps.test.ts @@ -0,0 +1,163 @@ +// Dependency closure for the cache keys. The scenarios are the ones +// TypeScript's incremental build tests exercise (dependents invalidated +// when an import changes, transitively; an unrelated file untouched; +// cycles; ambient declarations reaching every file) — with one +// difference: tsc compares the exported signature, this compares content, +// so a change that tsc would ignore invalidates here too. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { dependencyClosure, dependencySha, importSpecifiers, resolveImport } from '../lib/deps.js'; +import { readCache, reportCacheKey, transformCacheKey, writeCache } from '../lib/cache.js'; + +const FIXTURES = fileURLToPath(new URL('./deps-fixtures', import.meta.url)); + +let root: string; +let tsconfig: string; +const component = (name: string) => path.join(root, 'app', 'components', name); +const read = (name: string) => fs.readFileSync(component(name), 'utf8'); +const edit = (file: string, append: string) => fs.appendFileSync(file, append); +const shaOf = (name: string) => dependencySha(component(name), read(name), tsconfig); + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'hve-deps-')); + fs.cpSync(FIXTURES, root, { recursive: true }); + fs.mkdirSync(path.join(root, 'node_modules', 'some-pkg'), { recursive: true }); + fs.writeFileSync(path.join(root, 'node_modules', 'some-pkg', 'index.js'), 'export default 1;'); + tsconfig = path.join(root, 'tsconfig.json'); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('importSpecifiers', () => { + it('finds static, side-effect, type and dynamic imports once each', () => { + expect(importSpecifiers(read('root.gts'))).toEqual([ + '@glimmer/component', + 'app/components/mid', + './leaf-two.js', + './dir', + 'operations', + 'some-pkg', + 'side-effect-only', + ]); + expect(importSpecifiers(read('dir/index.ts'))).toEqual(['../leaf']); + }); +}); + +describe('resolveImport', () => { + it('resolves relative, paths-mapped, js-to-ts and directory imports to project files', () => { + const from = component('root.gts'); + expect(resolveImport('app/components/mid', from, tsconfig)).toBe(component('mid.gts')); + expect(resolveImport('./leaf-two.js', from, tsconfig)).toBe(component('leaf-two.ts')); + expect(resolveImport('./dir', from, tsconfig)).toBe(component('dir/index.ts')); + expect(resolveImport('operations', from, tsconfig)).toBe(path.join(root, 'types', 'operations.d.ts')); + }); + + it('treats packages and unresolved specifiers as external', () => { + const from = component('root.gts'); + expect(resolveImport('@glimmer/component', from, tsconfig)).toBeNull(); + expect(resolveImport('some-pkg', from, tsconfig)).toBeNull(); + expect(resolveImport('./missing', from, tsconfig)).toBeNull(); + }); + + it('reads paths and baseUrl through a jsonc tsconfig with extends', () => { + expect(resolveImport('app/components/leaf', component('mid.gts'), tsconfig)).toBe(component('leaf.gts')); + }); +}); + +describe('dependencyClosure', () => { + it('is the transitive set of project files, without the root or packages', () => { + expect(dependencyClosure(component('root.gts'), read('root.gts'), tsconfig)).toEqual( + [component('dir/index.ts'), component('leaf-two.ts'), component('leaf.gts'), component('mid.gts'), path.join(root, 'types', 'operations.d.ts')].sort(), + ); + }); + + it('terminates on cycles and includes both sides', () => { + 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', () => { + it('changes for every dependent, transitively, when a leaf changes', () => { + const before = { root: shaOf('root.gts'), mid: shaOf('mid.gts'), leaf: shaOf('leaf.gts') }; + edit(component('leaf.gts'), '\n'); + expect(shaOf('root.gts')).not.toBe(before.root); + expect(shaOf('mid.gts')).not.toBe(before.mid); + // The leaf's own content is not part of its dependency sha; that is the file sha's job. + expect(shaOf('leaf.gts')).toBe(before.leaf); + }); + + it('does not change when an unrelated file changes', () => { + const before = shaOf('root.gts'); + edit(component('unrelated.gts'), '\n'); + expect(shaOf('root.gts')).toBe(before); + }); + + // Ambient declarations and the lockfile are hashed once per process, so + // the change is shown with a second copy of the project. + it('differs for every file between projects whose ambient declarations or lockfile differ', () => { + const copy = (mutate: (dir: string) => void) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hve-deps-copy-')); + fs.cpSync(root, dir, { recursive: true }); + mutate(dir); + const at = (name: string) => path.join(dir, 'app', 'components', name); + return { + dir, + sha: (name: string) => dependencySha(at(name), fs.readFileSync(at(name), 'utf8'), path.join(dir, 'tsconfig.json')), + }; + }; + const same = copy(() => {}); + const ambient = copy((dir) => edit(path.join(dir, 'types', 'global.d.ts'), '\n')); + const lockfile = copy((dir) => edit(path.join(dir, 'pnpm-lock.yaml'), '\n')); + try { + for (const name of ['root.gts', 'unrelated.gts']) { + expect(same.sha(name)).toBe(shaOf(name)); + expect(ambient.sha(name)).not.toBe(shaOf(name)); + expect(lockfile.sha(name)).not.toBe(shaOf(name)); + } + } finally { + for (const { dir } of [same, ambient, lockfile]) fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('sees a dependency edited after it was first read', () => { + const before = shaOf('mid.gts'); + // Same size, different content: mtime moves. + const leaf = component('leaf.gts'); + fs.writeFileSync(leaf, read('leaf.gts').replace('span', 'div ')); + fs.utimesSync(leaf, new Date(), new Date(Date.now() + 5000)); + expect(shaOf('mid.gts')).not.toBe(before); + }); +}); + +describe('cache keys include the closure', () => { + it('glint cache misses for the consumer after its import changes', () => { + const file = component('mid.gts'); + const contents = read('mid.gts'); + const result = { attrTypeMap: new Map(), componentTagMap: new Map([['2:1', 'span']]), componentAttrMap: new Map() }; + writeCache(file, contents, tsconfig, 'ts6', result); + expect(readCache(file, contents, tsconfig, 'ts6')).not.toBeNull(); + edit(component('leaf.gts'), '\n'); + expect(readCache(file, contents, tsconfig, 'ts6')).toBeNull(); + }); + + it('transform and report keys change when an import changes and not when an unrelated file does', () => { + const file = component('mid.gts'); + const contents = read('mid.gts'); + const keys = () => [transformCacheKey(file, contents, tsconfig, 'ts6'), reportCacheKey(file, contents, {}, '11.0.0', tsconfig, 'ts6')]; + const before = keys(); + edit(component('unrelated.gts'), '\n'); + expect(keys()).toEqual(before); + edit(component('leaf.gts'), '\n'); + expect(keys()[0]).not.toBe(before[0]); + expect(keys()[1]).not.toBe(before[1]); + }); +}); diff --git a/transform.ts b/transform.ts index d3530aa..ca27ca5 100644 --- a/transform.ts +++ b/transform.ts @@ -306,7 +306,7 @@ function* transformGlimmer(source: Source): Generator { // Glint result. On a hit nothing below `computeTemplates` runs: no // content-tag parse, no Glint, no blanking. const tsconfigPath = findTsconfig(filename); - const key = transformCacheKey(data, tsconfigPath, tsconfigPath ? backendKindFor(tsconfigPath) : 'none'); + const key = transformCacheKey(filename, data, tsconfigPath, tsconfigPath ? backendKindFor(tsconfigPath) : 'none'); let templates = readTransformCache(filename, key); if (!templates) { templates = computeTemplates(filename, data); From 90ff4a1762858cbfcaad7742f4e898564ae1e840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 12:00:44 +0200 Subject: [PATCH 2/5] Exclude the dependency fixture project from the test typecheck Cowritten by Claude --- tsconfig.test.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.test.json b/tsconfig.test.json index c1fafe5..d655826 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["*.ts", "lib/**/*.ts", "test/**/*.ts", "ecosystem/**/*.ts"], - "exclude": ["node_modules", "dist", "ecosystem/.cache"] + "exclude": ["node_modules", "dist", "ecosystem/.cache", "test/deps-fixtures"] } From 26faea8569170aa947968d2a46b15b86cb1f6626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 12:10:42 +0200 Subject: [PATCH 3/5] Rename resolveImport to resolveProjectImport Cowritten by Claude --- lib/deps.ts | 8 ++++---- test/deps.test.ts | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/deps.ts b/lib/deps.ts index 44bbdc2..0346072 100644 --- a/lib/deps.ts +++ b/lib/deps.ts @@ -192,16 +192,16 @@ function matchPattern(pattern: string, spec: string): string | null { const resolutionByKey = new Map(); /** The project file `spec` refers to from `fromFile`, or null when it is a package or unresolved. */ -export function resolveImport(spec: string, fromFile: string, tsconfigPath: string): string | null { +export function resolveProjectImport(spec: string, fromFile: string, tsconfigPath: string): string | null { const memoKey = `${tsconfigPath}\0${path.dirname(fromFile)}\0${spec}`; const memo = resolutionByKey.get(memoKey); if (memo !== undefined) return memo; - const resolved = resolveImportUncached(spec, fromFile, tsconfigPath); + const resolved = resolveProjectImportUncached(spec, fromFile, tsconfigPath); resolutionByKey.set(memoKey, resolved); return resolved; } -function resolveImportUncached(spec: string, fromFile: string, tsconfigPath: string): string | null { +function resolveProjectImportUncached(spec: string, fromFile: string, tsconfigPath: string): string | null { const projectRoot = path.dirname(tsconfigPath); const candidates: string[] = []; if (spec.startsWith('.') || path.isAbsolute(spec)) { @@ -231,7 +231,7 @@ export function dependencyClosure(file: string, contents: string, tsconfigPath: const root = path.resolve(file); const seen = new Set([root]); const resolveAll = (from: string, specs: string[]) => - specs.map((spec) => resolveImport(spec, from, tsconfigPath)).filter((dep): dep is string => dep !== null); + specs.map((spec) => resolveProjectImport(spec, from, tsconfigPath)).filter((dep): dep is string => dep !== null); const queue: string[][] = [resolveAll(root, importSpecifiers(contents))]; while (queue.length > 0) { for (const dep of queue.pop()!) { diff --git a/test/deps.test.ts b/test/deps.test.ts index e6c2a30..0f0a91e 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, resolveImport } from '../lib/deps.js'; +import { dependencyClosure, dependencySha, importSpecifiers, resolveProjectImport } from '../lib/deps.js'; import { readCache, reportCacheKey, transformCacheKey, writeCache } from '../lib/cache.js'; const FIXTURES = fileURLToPath(new URL('./deps-fixtures', import.meta.url)); @@ -47,24 +47,24 @@ describe('importSpecifiers', () => { }); }); -describe('resolveImport', () => { +describe('resolveProjectImport', () => { it('resolves relative, paths-mapped, js-to-ts and directory imports to project files', () => { const from = component('root.gts'); - expect(resolveImport('app/components/mid', from, tsconfig)).toBe(component('mid.gts')); - expect(resolveImport('./leaf-two.js', from, tsconfig)).toBe(component('leaf-two.ts')); - expect(resolveImport('./dir', from, tsconfig)).toBe(component('dir/index.ts')); - expect(resolveImport('operations', from, tsconfig)).toBe(path.join(root, 'types', 'operations.d.ts')); + expect(resolveProjectImport('app/components/mid', from, tsconfig)).toBe(component('mid.gts')); + expect(resolveProjectImport('./leaf-two.js', from, tsconfig)).toBe(component('leaf-two.ts')); + expect(resolveProjectImport('./dir', from, tsconfig)).toBe(component('dir/index.ts')); + expect(resolveProjectImport('operations', from, tsconfig)).toBe(path.join(root, 'types', 'operations.d.ts')); }); it('treats packages and unresolved specifiers as external', () => { const from = component('root.gts'); - expect(resolveImport('@glimmer/component', from, tsconfig)).toBeNull(); - expect(resolveImport('some-pkg', from, tsconfig)).toBeNull(); - expect(resolveImport('./missing', from, tsconfig)).toBeNull(); + expect(resolveProjectImport('@glimmer/component', from, tsconfig)).toBeNull(); + expect(resolveProjectImport('some-pkg', from, tsconfig)).toBeNull(); + expect(resolveProjectImport('./missing', from, tsconfig)).toBeNull(); }); it('reads paths and baseUrl through a jsonc tsconfig with extends', () => { - expect(resolveImport('app/components/leaf', component('mid.gts'), tsconfig)).toBe(component('leaf.gts')); + expect(resolveProjectImport('app/components/leaf', component('mid.gts'), tsconfig)).toBe(component('leaf.gts')); }); }); From 93551ed3cc93d0b336f5e37d68b04ea465f32768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 29 Aug 2026 12:26:53 +0200 Subject: [PATCH 4/5] Dependency closure: follow tsc semantics, re-validate memos, fix foot guns Review fixes for the import closure: - The tsconfig reader handles strings that end in a backslash, tolerates malformed `paths` and unparsable files (warns once, keeps resolving), follows `extends` arrays with later entries winning, resolves `paths` against the merged `baseUrl` or the declaring config's directory, prefers the longest pattern prefix, and follows package `extends` through the `tsconfig` field. - Resolved files are taken by real path; only `node_modules` is external, so workspace sources reached through a symlink or `../` are tracked. - Source files with `declare module` / `declare global` count as project-wide inputs next to `.d.ts` files. - Memos re-validate: resolutions and probes on the mtime of the directories they touched, tsconfig paths on the chain's content. The CLI opts into a static view of the file system for its run (`assumeStaticFileSystem`), like a non-watch tsc. - The Glint cache reads and writes under one dependency sha, computed once, so a dependency edited during the analysis is not stored as reflected. Not computed when the cache is off. - Without Glint the closure is not part of the key, and not computed. - `sha256` lives in one place; the cache header names the dependency sha. Cowritten by Claude --- lib/backend/ts6.ts | 18 ++- lib/cache.ts | 45 ++++-- lib/deps.ts | 363 +++++++++++++++++++++++++++++++-------------- lib/glint.ts | 9 +- run.ts | 2 + test/deps.test.ts | 127 +++++++++++++++- 6 files changed, 423 insertions(+), 141 deletions(-) diff --git a/lib/backend/ts6.ts b/lib/backend/ts6.ts index 4dd0669..5cc3c18 100644 --- a/lib/backend/ts6.ts +++ b/lib/backend/ts6.ts @@ -9,7 +9,7 @@ import { createRequire } from 'node:module'; import type * as TS from 'typescript'; import { isComponentTag } from '../../blank.js'; -import { readCache, writeCache } from '../cache.js'; +import { cacheDependencies, readCache, writeCache } from '../cache.js'; import type { OpenedFile, PreloadProgress, @@ -479,7 +479,8 @@ export function createTs6Backend(deps: Ts6Deps, tsconfigPath: string): TypeBacke } // If a cached extraction exists for this file, skip the rewrite — // we'll never need its rewritten contents in the program. - if (readCache(filename, contents, tsconfigPath, 'ts6')) { + const dependencies = cacheDependencies(filename, contents, tsconfigPath); + if (readCache(filename, contents, tsconfigPath, 'ts6', dependencies)) { cached++; onProgress?.({ done, total: filenames.length, phase: 'rewrite' }); continue; @@ -503,11 +504,14 @@ export function createTs6Backend(deps: Ts6Deps, tsconfigPath: string): TypeBacke // `