Skip to content

Cache keys include the import closure - #57

Merged
johanrd merged 5 commits into
mainfrom
deps-cache-key
Aug 29, 2026
Merged

johanrd merged 5 commits into
mainfrom
deps-cache-key

Conversation

@johanrd

@johanrd johanrd commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes the cross-file caveat from #55 and #56. Before this change, a cache entry was keyed on the content of one file only. An edit to an imported component did not invalidate the consumers of that component. The consumers kept a stale entry until they changed or the plugin version changed.

Now every cache (Glint, transform, report) includes dependencySha in its key. This sha covers the content of every project file that the file imports, transitively. It also covers the ambient .d.ts files of the project and the lockfile (lib/deps.ts).

This is the shape of the referencedMap in the incremental mode of tsc. The key uses content, not the exported signature. As a result it is stricter than tsc, never looser.

The scanner finds imports in the text. It resolves them in this order:

  • relative paths
  • tsconfig paths and baseUrl (with extends, jsonc accepted)
  • the TypeScript .js to .ts rewrite
  • extension probing, with .gts and .gjs first
  • directory index files

A specifier that does not resolve to a project file is a package. The lockfile sha covers packages.

Tests. test/deps-fixtures is a small project: alias imports, .js to .ts, a directory index, a "*": ["./types/*"] mapping, a cycle, an unrelated file and an ambient .d.ts. test/deps.test.ts covers the scenarios that the incremental build tests of TypeScript exercise:

  • dependents are invalidated, transitively
  • an unrelated file is not invalidated
  • cycles terminate
  • an ambient or lockfile change reaches every file
  • a dependency edited after the first read is seen (mtime)
  • the three cache keys, end to end

13 tests. Both lanes are green (309 passed, 1 expected fail).

What invalidates what.

  • A .gts, .gjs or .ts file is keyed on its own content, its import closure, and the content of the project-wide inputs (ambient .d.ts files, files with declare module / declare global, the lockfile). An edit invalidates the file and its downstream consumers. An edit to a project-wide input invalidates everything.
  • A .hbs template has no imports. Components reach it only through the registry. So it is keyed on the project-wide inputs with everything they import. An edit to any file that the registry reaches invalidates every template. This is the .hbs trade-off: correct first.
  • Known gap for .gts: a type that reaches a file only through a registry (@service declare session) is covered by the content of the service file, not by what the service file imports. A change in a type that the service imports, and that the consumer does not import, is not seen.

Cost, console app (354 .gts files, no .hbs, one process per run):

  • warm run: 0.3 s to 0.7 s. The plugin reads and hashes every project file once, and walks the closure of each file (about 5,000 stat calls in total).
  • cold run: unchanged (15 s)
  • --no-glint: unchanged. Without Glint nothing crosses file boundaries, so the closure is not part of the key and is not computed.
  • edit of a leaf component (equipment-list.gts, 7 direct importers): 146 downstream consumers are recomputed. This is the difference between content and signature: a template-only edit invalidates everything downstream. It is the price of not having signatures.
  • edit of a service with a declare module '@ember/service' registry augmentation: every file is recomputed.

Memos. File records re-validate on mtime and size. Resolutions and probes re-validate on the mtime of every directory they touched, so a file created later is seen. The tsconfig paths re-validate on the content of the config chain. The list of project-wide input files is found once per process. validate-gts calls assumeStaticFileSystem() at start and trusts the memos for its run, like a non-watch tsc. A long-lived host keeps re-validating.

Review. A deep review found 10 items. Commits 2 and 3 fix the tsconfig reader (backslash in strings, malformed paths, extends order, inherited baseUrl, longest prefix, package tsconfig field), track workspace sources by real path, count module augmentations as project-wide inputs, re-validate the memos, and read and write the Glint cache under one dependency sha in both backends. The one item not taken: a compiler-derived dependency list. The tsgo sync API exposes getSourceFileNames and getSourceFile only, with no per-file module resolution, so the text scanner stays.

Cowritten by Claude

@johanrd johanrd added enhancement New feature or request run-bench Run the benchmark comparison on this pull request labels Aug 29, 2026
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
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
extract small template 830.90 µs 818.05 µs -1.5%
🟢 extract medium template 3.71 ms 3.30 ms -11.1%
🟡 extract cross-file resolution 3.15 ms 2.99 ms -5.2%
extract large template 722.30 µs 695.76 µs -3.7%
🟡 cold run (cache off) 6864.83 ms 6404.48 ms -6.7%
warm run (all cached) 955.06 ms 935.03 ms -2.1%
one cached file 342.01 ms 352.43 ms +3.0%
🟢 no glint 5735.06 ms 5117.03 ms -10.8%

🟢 faster · 🔴 slower · 🟡 5–10 %, possibly noise · ⚪ within 5 %

Full output
clk: ~3.10 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.19.0 (x64-linux)

benchmark                    avg (min … max) p75 / p99    (min … top 1%)
-------------------------------------------- -------------------------------
extract small template          1.25 ms/iter   1.28 ms  █                   
                       (646.57 µs … 5.77 ms)   4.25 ms ██▂                  
                     (  5.41 kb …   3.22 mb) 309.33 kb ███▅▄▃▂▂▂▂▁▂▂▂▂▁▂▂▂▂▁

extract medium template         4.40 ms/iter   4.85 ms  █                   
                        (2.86 ms … 11.58 ms)  10.03 ms ▂█▇▃                 
                     ( 58.95 kb …   4.24 mb) 770.07 kb ████▆▆▅▃▅▅▂▂▃▁▂▂▂▃▂▁▂

extract cross-file resolution   3.63 ms/iter   3.78 ms  █▂                  
                        (2.54 ms … 11.63 ms)   8.44 ms  ██                  
                     ( 32.43 kb …   1.48 mb) 426.88 kb █████▃▄▄▂▂▂▄▂▂▃▁▁▁▁▁▁

extract large template        760.46 µs/iter 769.54 µs  █▆                  
                       (610.61 µs … 3.29 ms)   1.95 ms  ██                  
                     (  4.54 kb …   1.70 mb)  50.83 kb ████▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

whole process (min / p50 of 3 runs, 21 files)
  cold run (cache off)           6703 ms /   6865 ms
  warm run (all cached)           951 ms /    955 ms
  one cached file                 342 ms /    342 ms
  no glint                       5278 ms /   5735 ms
clk: ~3.10 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.19.0 (x64-linux)

benchmark                    avg (min … max) p75 / p99    (min … top 1%)
-------------------------------------------- -------------------------------
extract small template          1.25 ms/iter   1.19 ms ▇█                   
                       (645.43 µs … 7.80 ms)   5.05 ms ██                   
                     ( 24.66 kb …   3.05 mb) 309.60 kb ███▅▃▂▂▂▂▂▂▃▂▃▁▂▁▁▁▁▁

extract medium template         3.93 ms/iter   4.26 ms  █                   
                         (2.78 ms … 8.94 ms)   8.74 ms  █                   
                     ( 93.75 kb …   4.07 mb) 764.77 kb ███▆▅▄▃▃▂▂▃▁▃▂▂▁▁▂▁▁▂

extract cross-file resolution   3.43 ms/iter   3.68 ms  █                   
                         (2.45 ms … 9.93 ms)   8.62 ms ▂█▇                  
                     (305.23 kb …   1.36 mb) 427.51 kb ████▄▂▄▆▃▃▃▂▁▁▁▁▁▁▁▁▁

extract large template        781.46 µs/iter 752.30 µs ▃█                   
                       (604.25 µs … 7.84 ms)   3.29 ms ██                   
                     (  4.37 kb …   1.89 mb)  51.35 kb ██▂▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

whole process (min / p50 of 3 runs, 21 files)
  cold run (cache off)           6399 ms /   6404 ms
  warm run (all cached)           930 ms /    935 ms
  one cached file                 352 ms /    352 ms
  no glint                       5055 ms /   5117 ms

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It reimplements module resolution (tsconfig paths/extends, JSONC parsing, extension probing) whose subtle edge cases directly affect cache-staleness correctness and warrant human review.

Pull request overview

This PR closes the cross-file caching caveat from #55/#56: previously a cache entry (Glint extraction, transform output, and CLI report) was keyed only on the file's own content, so editing an imported component did not invalidate its consumers. A new lib/deps.ts module computes a dependencySha — the hashed content of every project file a file imports transitively, plus the project's ambient .d.ts files and lockfile — and this sha is folded into all three cache keys. Imports are discovered by text scan and resolved through relative paths, tsconfig paths/baseUrl (following extends, JSONC-tolerant), TypeScript's .js.ts rewrite, .gts/.gjs-first extension probing, and directory index files; anything not resolving to a project file is treated as external and covered by the lockfile sha. The approach mirrors tsc's incremental referencedMap but is content-keyed, making it stricter (never looser) than tsc.

Changes:

  • Add lib/deps.ts: import scanning, memoized module resolution, dependency-closure walk, and dependencySha.
  • Thread a filename argument into transformCacheKey/reportCacheKey and include dependencySha in the Glint readCache/writeCache, transform, and report keys.
  • Add a test/deps-fixtures project plus test/deps.test.ts (13 tests) and update test/cache.test.ts/README accordingly.
File summaries
File Description
lib/deps.ts New module: import discovery, tsconfig paths resolution, dependency closure, and dependencySha.
lib/cache.ts Adds dependencySha to CacheEntry and all three cache keys; adds filename params.
transform.ts Passes filename to transformCacheKey.
run.ts Passes file to reportCacheKey.
test/deps.test.ts New tests covering import scanning, resolution, closure, invalidation, and cache-key integration.
test/deps-fixtures/* Small fixture project (aliases, .js.ts, dir index, cycle, ambient .d.ts, lockfile).
test/cache.test.ts Updates existing key calls for the new filename parameter.
README.md Documents that caches now key on the import closure, ambient files, and lockfile.
Review details

Files not reviewed (1)

  • test/deps-fixtures/pnpm-lock.yaml: Generated file
  • Files reviewed: 19/22 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/deps.ts Outdated
… 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
…go preload

A `.gts`/`.gjs`/`.ts` file is keyed on its own import closure and the
content of the project-wide inputs (ambient declarations, augmentations,
lockfile): an edit invalidates its downstream consumers. A `.hbs`
template has no imports and reaches components only through the
registry, so it is keyed on the inputs with everything they import: an
edit to any file the registry reaches invalidates every template.

The tsgo preload reads and writes the Glint cache under one dependency
sha like the ts6 preload; the CLI assumes a static file system from the
start, before any preload. Directories are re-validated once per
`dependencySha` instead of once per closure walk.

Cowritten by Claude
@johanrd
johanrd requested a balanced review from Copilot August 29, 2026 10:58
@johanrd
johanrd merged commit dd5ec73 into main Aug 29, 2026
4 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It reworks cache-invalidation correctness with a large new resolver/closure module whose stale-entry edge cases warrant a final human review despite comprehensive tests.

Review details

Files not reviewed (1)

  • test/deps-fixtures/pnpm-lock.yaml: Generated file
  • Files reviewed: 22/25 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread lib/cache.ts
Comment on lines +311 to +312
function dependenciesForKey(filename: string, contents: string, tsconfigPath: string | null): string {
return process.env['HVE_GLINT'] === '0' ? 'no-glint' : dependencySha(filename, contents, tsconfigPath);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request run-bench Run the benchmark comparison on this pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants