as-test 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Change Log
2
2
 
3
+ ## 2026-06-01 - v1.6.0
4
+
5
+ ### Incremental test cache — only rebuild and rerun what changed
6
+
7
+ - feat: an opt-in incremental cache for `ast test` skips recompiling and rerunning specs whose inputs are unchanged since the last run, only acting on the ones that changed — themselves or via their dependency tree. Enable per run with `--cache`, or persistently with `"cache": true | "build" | "full"` in `as-test.config.json` (`--no-cache` overrides). Default is off. Because asc compilation is ~all of a run's wall-clock (a spec runs in microseconds but builds in seconds), skipping unchanged builds is the dominant win. New module `cli/build-cache.ts` persists a manifest under `.as-test/cache/`.
8
+ - feat: **Tier 1 (build cache)** — `build()` (`cli/commands/build-core.ts`) and the parallel `buildFileForMode` (`cli/index.ts`) skip the asc invocation when a spec, every file it imported, and the build signature (`getBuildReuseInfo`) are all unchanged and the `.wasm` still exists. Dependencies are captured from asc's actual file reads (reusing the existing `buildRecorderStorage`/`onReads` plumbing that previously only fed `--watch`), so editing a shared helper correctly rebuilds just its dependents. The cache context is threaded once via an `AsyncLocalStorage` (`cacheStorage`) set in `runTestModes`, so no per-variant plumbing was needed and `--watch` (which keeps its own in-memory graph) is untouched.
9
+ - feat: **Tier 2 (run replay)** — under `"full"`/`--cache`, `run()` (`cli/commands/run-core.ts`) replays a stored passing report instead of spawning the runtime, so cached specs still appear in the live output and in the summary/coverage. Replayed specs keep the coloured verdict badge (white text) but render the **filename dimmed with a `(cache)` tag in place of the timing**, so freshly-run specs stand out — in both the default reporter (`cli/reporters/default.ts`) and the multi-mode matrix display (`formatMatrixFileResultLine`). Only passing reports are replayed; a failing spec re-runs for fresh output. A rebuild clears the stored report, so a changed spec never replays a stale result.
10
+ - feat: change detection uses a content hash (sha256) with an mtime+size fast-path — correct across `git checkout`/`clone`/`touch` (no false rebuilds) yet cheap when nothing changed. The whole cache is invalidated when the as-test version changes (transform output may differ); replay additionally keys on the runtime command and snapshot file contents. The build signature hashes only as-test's _declared_ env (config + `buildOptions.env` + the `AS_TEST_*` flags), not the inherited `process.env`, so volatile ambient vars like `FORCE_COLOR`/`TERM`/`CI` can't spuriously invalidate the cache between runs.
11
+ - note: a reachability-based dep-pruning mode (`cache: "reachable"`) was prototyped but **not shipped** — it was unsound for AssemblyScript's compile-time inlining (inlined `const`s, `static readonly` fields, `@inline` bodies, and re-export barrels can change a spec's output without being "reachable"), so it could serve a stale pass. `"reachable"` is accepted as a deprecated alias for `"full"`, which tracks the complete dependency set and is always correct.
12
+ - feat: the run summary gains a **`Cache:` line** (between `Modes:` and `Time:`), rendered in the same aligned three-column layout as the other totals — e.g. `Cache: 46 cached, 0 skipped, 46 total` (counted per file/mode execution; `skipped` = ran fresh). It only appears when the cache is active — each report carries a `cached` flag (true = replayed, false = freshly run) that the default reporter tallies in `onRunComplete`.
13
+ - feat: **time-based expiry** via the object config form — `"cache": { "type": "full", "maxTime": "1h" }`. `maxTime` accepts `ms`/`s`/`m`/`h`/`d` durations; an entry built longer ago than that is treated as stale and rebuilt+rerun (which resets its timer). `type` selects the tier (`build`/`full`); the bare boolean/string forms still work. Parsed by `parseDurationMs`, resolved by `resolveCacheSettings` (`cli/util.ts`), enforced per-entry in `BuildCache.isBuildFresh` against a `builtAt` timestamp.
14
+ - fix: the cache is now honored under `--watch` — previously `--cache --watch` silently ignored the cache (watch only set up its own in-memory dependency graph), so every watch run rebuilt from scratch. Each watch iteration now runs inside the cache context (a fresh load per run so `maxTime` stays current), making the initial watch run and `a` (re-run all) replay unchanged specs. `build()`'s read-recorder forwards to the watch graph recorder so both stay correct together; entry-pruning is skipped under watch (scoped re-runs resolve only a subset). A new **`c` keybinding toggles the cache** live; toggling `c` (and `w`) now rewrites the watch footer in place — `c = cache (on/off)` — instead of appending a message + a fresh footer each time.
15
+ - feat: the cache is bypassed automatically when it cannot be trusted — `--fuzz` (non-deterministic), a custom build command, and (for replay only) `--create-snapshots`/`--overwrite-snapshots`. `ast clean` removes `.as-test/cache`.
16
+ - fix: under `--parallel`, per-file result lines are now emitted in resolved order (the `localeCompare` sort `resolveSpecFiles` applies after globbing) instead of completion order. Previously a cache replay finished instantly and printed ahead of still-running fresh specs, scrambling the list; `ParallelQueueDisplay` now buffers completed output and flushes the contiguous prefix in start order, so cached and freshly-run specs stay interleaved in their original order.
17
+ - chore: the cache manifest and per-spec reports under `.as-test/cache/` are gzipped (`manifest.json.gz`, `reports/*.json.gz`) — they're large, repetitive JSON dominated by coverage points, so this shrinks the cache directory ~10× (≈8 MB → ≈0.8 MB on as-test's own suite) at negligible read/write cost.
18
+
19
+ ## 2026-06-01 - v1.5.3
20
+
21
+ ### A file with no runnable tests is skipped, not crashed
22
+
23
+ - fix: a spec file whose only suites are skip variants (`xdescribe`/`xtest`/`xit`) is now reported as **SKIP** instead of failing with `missing report payload from test runtime`. The transform's auto-`run()` injection keys off `analyzeSourceText` → `hasSuiteCalls` (`transform/src/index.ts`), whose regex was `\b(?:describe|test|it|only|xonly|todo|fuzz|xfuzz)`. `\bdescribe` can't match `xdescribe(` — `x` and `d` are both word chars, so there's no word boundary — and likewise `\btest`/`\bit` miss `xtest`/`xit`. A file whose only suites were skipped therefore reported `hasSuiteCalls = false`, `run()` was never injected, the wasm emitted no lifecycle frames and exited `0`, and the CLI read that silent-but-clean exit as a runtime crash. The regex now matches the `x?` variants (`x?describe|x?test|x?it|x?only|todo|x?fuzz`), so `run()` is injected and the file reports itself skipped (with the suite shown and counted). `looksLikeAsTestImport` got the same `x?` treatment so a file importing _only_ an x-variant still resolves its `run` import path.
24
+ - feat: a spec file with no suites at all (empty, or only imports/comments) is now reported as a skipped file with a `… contains no tests; marked as skipped` warning, instead of `missing report payload`. Such a file never injects `run()` either, so `runProcess` (`cli/commands/run-core.ts`) now treats a clean exit (`code 0`) with zero data frames, no `file-start`/`file-end` events, no suite starts, and no stderr as an empty test file — returning a skip report (`createEmptyFileSkipReport`, `suites: []` so it counts as one skipped file and zero skipped suites) rather than a crash. A file that _does_ have suites always emits `file-start` before anything can go wrong, so this only ever fires for genuinely empty files.
25
+
26
+ ## 2026-06-01 - v1.5.2
27
+
28
+ ### Selectors resolve folders, files, and globs consistently
29
+
30
+ - feat: positional selectors for `ast test`/`ast run`/`ast build` now resolve through a single shared resolver (`cli/selectors.ts:resolveSpecFiles`), replacing three drifting private copies of `resolveInputPatterns` (in `build-core`, `run-core`, and `index`). Three input shapes are supported:
31
+ - **Bare folders/files/globs** (no leading `./`) resolve against the configured input root(s) — the static prefix of each `input` glob, e.g. `assembly/__tests__` — searched recursively, and fall back to the cwd only if nothing matched there: `ast test rfc/` → `<root>/**/rfc/**/*.spec.ts`; `ast test foo` → `<root>/**/foo.spec.ts`; `ast test 'rfc/*.spec.ts'` → `<root>/**/rfc/*.spec.ts` (the user's glob appended verbatim). A bare path shorthand like `nested/array` is tried as a cwd path first, then anchored to the test folder.
32
+ - **`./`-prefixed** selectors (and absolute / `~` paths) are cwd-relative only; on a miss we emit a `did you mean "rfc/*.spec.ts"` hint pointing at the test-folder form when that would have matched.
33
+ - feat: a bare selector that matches under more than one configured input root is flagged with a `WARN` (it still runs everything that matched), and a selector that matches nothing emits a `WARN` naming where it looked. Warnings are deduped by text across the orchestrator + per-file build/run passes (`emitSelectorWarnings`), so each prints once per invocation. Folder selectors (`rfc/`) and `,`-joined bare names (`a,b`) are recognized; selectors with an internal path separator (e.g. the orchestrator's own `assembly/__tests__/foo.spec.ts`) are still treated as direct cwd paths, preserving existing per-file dispatch.
34
+
3
35
  ## 2026-05-30 - v1.5.1
4
36
 
5
37
  ### An early-exiting runtime now fails instead of warning
@@ -87,6 +87,28 @@
87
87
  "description": "Reserved config field used by existing projects.",
88
88
  "default": "none"
89
89
  },
90
+ "cache": {
91
+ "description": "Incremental test cache (opt-in). false = off; \"build\" = skip recompiling unchanged specs; \"full\"/true = also replay passing run results; \"reachable\" = full + reachability-pruned deps. The object form adds \"maxTime\" to expire entries older than that. Override per run with --cache / --no-cache.",
92
+ "oneOf": [
93
+ { "type": "boolean" },
94
+ { "type": "string", "enum": ["build", "full", "reachable"] },
95
+ {
96
+ "type": "object",
97
+ "additionalProperties": false,
98
+ "properties": {
99
+ "type": {
100
+ "type": "string",
101
+ "enum": ["build", "full", "reachable"]
102
+ },
103
+ "maxTime": {
104
+ "type": "string",
105
+ "description": "Entry expiry, e.g. \"1h\", \"30m\", \"90s\", \"7d\"."
106
+ }
107
+ }
108
+ }
109
+ ],
110
+ "default": false
111
+ },
90
112
  "coverage": {
91
113
  "description": "Coverage settings. Use a boolean for quick toggle or object for options.",
92
114
  "oneOf": [
@@ -378,6 +400,24 @@
378
400
  "type": "string",
379
401
  "description": "Mode-specific asconfig path or \"none\"."
380
402
  },
403
+ "cache": {
404
+ "description": "Mode-specific incremental cache setting.",
405
+ "oneOf": [
406
+ { "type": "boolean" },
407
+ { "type": "string", "enum": ["build", "full", "reachable"] },
408
+ {
409
+ "type": "object",
410
+ "additionalProperties": false,
411
+ "properties": {
412
+ "type": {
413
+ "type": "string",
414
+ "enum": ["build", "full", "reachable"]
415
+ },
416
+ "maxTime": { "type": "string" }
417
+ }
418
+ }
419
+ ]
420
+ },
381
421
  "coverage": {
382
422
  "description": "Mode-specific coverage settings.",
383
423
  "oneOf": [
@@ -0,0 +1,278 @@
1
+ import { createHash } from "crypto";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { gunzipSync, gzipSync } from "zlib";
12
+ import * as path from "path";
13
+ import { AsyncLocalStorage } from "async_hooks";
14
+ // Persisted incremental cache for `ast test`. Two tiers:
15
+ // Tier 1 (build): skip the asc compile when a spec + every file it imported is
16
+ // byte-for-byte unchanged and the build signature still matches.
17
+ // Tier 2 (replay): skip running and replay the stored per-file report when the
18
+ // spec is build-fresh AND the runtime command + snapshot file are unchanged.
19
+ // Both read the run-scoped context in `cacheStorage` (set once by the test
20
+ // orchestrator), mirroring how `buildRecorderStorage` threads the watch
21
+ // recorder down without plumbing a param through every call site.
22
+ const CACHE_FORMAT_VERSION = 1;
23
+ const MODE_KEY_DEFAULT = "__default__";
24
+ // Manifest and reports are gzipped (repetitive JSON, large coverage payloads).
25
+ const MANIFEST_FILE = "manifest.json.gz";
26
+ // Mirror dependency-graph.ts specKey: NUL cannot appear in a mode name or path,
27
+ // so the (mode, path) key can never collide.
28
+ function specKey(mode, spec) {
29
+ return `${mode ?? MODE_KEY_DEFAULT}\u0000${path.resolve(spec)}`;
30
+ }
31
+ // asc reads its own bundled stdlib/toolchain on every build; those never change
32
+ // between runs and would balloon every entry's dep set, so drop them — same
33
+ // filter dependency-graph.ts applies for watch.
34
+ function isUninterestingDep(absPath) {
35
+ const normalized = absPath.replace(/\\/g, "/");
36
+ if (normalized.includes("/node_modules/assemblyscript/")) return true;
37
+ if (normalized.includes("/node_modules/binaryen/")) return true;
38
+ return false;
39
+ }
40
+ // `.as-test/cache` — sibling of build/logs/coverage, derived from the build root
41
+ // so a custom outDir keeps everything together. Always the un-mode-qualified
42
+ // base dir: the manifest is mode-scoped internally via its keys.
43
+ export function resolveCacheDir(baseOutDir) {
44
+ return path.join(path.dirname(baseOutDir), "cache");
45
+ }
46
+ export function sha256OfFile(absPath) {
47
+ try {
48
+ return createHash("sha256").update(readFileSync(absPath)).digest("hex");
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ export class BuildCache {
54
+ constructor(
55
+ cacheDir,
56
+ asTestVersion,
57
+ manifest,
58
+ // Entry expiry window in ms (null = none) and the run's reference time.
59
+ maxTimeMs,
60
+ now,
61
+ ) {
62
+ this.cacheDir = cacheDir;
63
+ this.asTestVersion = asTestVersion;
64
+ this.manifest = manifest;
65
+ this.maxTimeMs = maxTimeMs;
66
+ this.now = now;
67
+ this.dirty = false;
68
+ }
69
+ // Loads the manifest, self-healing to an empty cache on a missing/corrupt
70
+ // file or a format/version mismatch (a new as-test may emit different
71
+ // transform output, so the whole cache is invalidated on version bump).
72
+ static load(cacheDir, asTestVersion, opts = {}) {
73
+ const maxTimeMs = opts.maxTimeMs ?? null;
74
+ const now = opts.now ?? Date.now();
75
+ const empty = {
76
+ version: CACHE_FORMAT_VERSION,
77
+ asTestVersion,
78
+ entries: {},
79
+ };
80
+ const manifestPath = path.join(cacheDir, MANIFEST_FILE);
81
+ if (!existsSync(manifestPath)) {
82
+ return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(
86
+ gunzipSync(readFileSync(manifestPath)).toString("utf8"),
87
+ );
88
+ if (
89
+ parsed.version !== CACHE_FORMAT_VERSION ||
90
+ parsed.asTestVersion !== asTestVersion ||
91
+ typeof parsed.entries !== "object" ||
92
+ parsed.entries === null
93
+ ) {
94
+ return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
95
+ }
96
+ return new BuildCache(cacheDir, asTestVersion, parsed, maxTimeMs, now);
97
+ } catch {
98
+ return new BuildCache(cacheDir, asTestVersion, empty, maxTimeMs, now);
99
+ }
100
+ }
101
+ isBuildFresh(mode, spec, ctx) {
102
+ const entry = this.manifest.entries[specKey(mode, spec)];
103
+ if (!entry) return false;
104
+ if (entry.buildSignature !== ctx.signature) return false;
105
+ if (entry.coverageEnabled !== ctx.coverageEnabled) return false;
106
+ // Time-based expiry: an entry built longer ago than maxTime is stale.
107
+ // Entries from before this field existed (builtAt undefined) count as 0,
108
+ // so they expire on the first run with maxTime set.
109
+ if (
110
+ this.maxTimeMs != null &&
111
+ this.now - (entry.builtAt ?? 0) > this.maxTimeMs
112
+ ) {
113
+ return false;
114
+ }
115
+ if (!existsSync(entry.outFile)) return false;
116
+ for (const [dep, fp] of Object.entries(entry.deps)) {
117
+ if (!this.depUnchanged(dep, fp)) return false;
118
+ }
119
+ return true;
120
+ }
121
+ // Tier 2: can we replay the stored report instead of running? A non-null
122
+ // reportPath means the build was fresh this session (recordBuild clears it on
123
+ // any rebuild), so the build signature + deps were already validated by
124
+ // build() before run() — we only re-check the run-specific inputs here
125
+ // (runtime command + snapshot file).
126
+ canReplay(mode, spec, ctx) {
127
+ const entry = this.manifest.entries[specKey(mode, spec)];
128
+ if (!entry?.reportPath) return false;
129
+ if ((entry.runtimeCmd ?? null) !== (ctx.runtimeCmd ?? null)) return false;
130
+ if ((entry.snapshotSha ?? null) !== (ctx.snapshotSha ?? null)) return false;
131
+ if (!existsSync(entry.outFile)) return false;
132
+ return existsSync(path.join(this.cacheDir, entry.reportPath));
133
+ }
134
+ recordBuild(mode, spec, args) {
135
+ const absSpec = path.resolve(spec);
136
+ const deps = {};
137
+ const record = (file) => {
138
+ const abs = path.resolve(file);
139
+ if (isUninterestingDep(abs)) return;
140
+ if (deps[abs]) return;
141
+ const fp = this.fingerprint(abs);
142
+ if (fp) deps[abs] = fp;
143
+ };
144
+ record(absSpec); // a spec is always its own dependency
145
+ for (const file of args.deps) record(file);
146
+ const key = specKey(mode, spec);
147
+ const prior = this.manifest.entries[key];
148
+ // A rebuild means any stored report is stale: drop it so isReplayFresh
149
+ // fails until the fresh run records a new one.
150
+ this.removeReportFile(prior?.reportPath ?? null);
151
+ this.manifest.entries[key] = {
152
+ spec: absSpec,
153
+ mode: mode ?? null,
154
+ buildSignature: args.signature,
155
+ outFile: path.resolve(args.outFile),
156
+ coverageEnabled: args.coverageEnabled,
157
+ deps,
158
+ builtAt: this.now,
159
+ runtimeCmd: null,
160
+ snapshotSha: null,
161
+ reportPath: null,
162
+ };
163
+ this.dirty = true;
164
+ }
165
+ recordReport(mode, spec, args) {
166
+ const key = specKey(mode, spec);
167
+ const entry = this.manifest.entries[key];
168
+ if (!entry) return; // recordBuild must have run first
169
+ const relPath = path.join("reports", `${sha1(key)}.json.gz`);
170
+ const absPath = path.join(this.cacheDir, relPath);
171
+ mkdirSync(path.dirname(absPath), { recursive: true });
172
+ // Reports are large (coverage points) and highly compressible repetitive
173
+ // JSON — gzip keeps .as-test/cache small (~15x on coverage-heavy specs).
174
+ writeFileSync(absPath, gzipSync(Buffer.from(JSON.stringify(args.report))));
175
+ entry.reportPath = relPath;
176
+ entry.snapshotSha = args.snapshotSha;
177
+ entry.runtimeCmd = args.runtimeCmd;
178
+ this.dirty = true;
179
+ }
180
+ getReport(mode, spec) {
181
+ const entry = this.manifest.entries[specKey(mode, spec)];
182
+ if (!entry?.reportPath) return undefined;
183
+ try {
184
+ return JSON.parse(
185
+ gunzipSync(
186
+ readFileSync(path.join(this.cacheDir, entry.reportPath)),
187
+ ).toString("utf8"),
188
+ );
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ }
193
+ // Drop entries whose spec is no longer produced by the current input glob
194
+ // (deleted/renamed specs), along with their stored report files. liveKeys are
195
+ // specKey() strings for every (mode, spec) in this run.
196
+ prune(liveKeys) {
197
+ for (const [key, entry] of Object.entries(this.manifest.entries)) {
198
+ if (liveKeys.has(key)) continue;
199
+ this.removeReportFile(entry.reportPath ?? null);
200
+ delete this.manifest.entries[key];
201
+ this.dirty = true;
202
+ }
203
+ }
204
+ keyFor(mode, spec) {
205
+ return specKey(mode, spec);
206
+ }
207
+ save() {
208
+ if (!this.dirty) return;
209
+ mkdirSync(this.cacheDir, { recursive: true });
210
+ const manifestPath = path.join(this.cacheDir, MANIFEST_FILE);
211
+ const tmp = `${manifestPath}.tmp`;
212
+ writeFileSync(tmp, gzipSync(Buffer.from(JSON.stringify(this.manifest))));
213
+ renameSync(tmp, manifestPath);
214
+ this.dirty = false;
215
+ }
216
+ fingerprint(absPath) {
217
+ try {
218
+ const st = statSync(absPath);
219
+ const sha = sha256OfFile(absPath);
220
+ if (sha === null) return null;
221
+ return { sha, mtimeMs: st.mtimeMs, size: st.size };
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+ // Fast-path: trust mtime+size when both match (cheap stat, no read). Only when
227
+ // they differ do we hash — and if the hash still matches (e.g. git checkout
228
+ // rewrote mtime), refresh the stored stat so we don't re-hash next run.
229
+ depUnchanged(absPath, fp) {
230
+ let st;
231
+ try {
232
+ st = statSync(absPath);
233
+ } catch {
234
+ return false; // dep deleted
235
+ }
236
+ if (st.mtimeMs === fp.mtimeMs && st.size === fp.size) return true;
237
+ const sha = sha256OfFile(absPath);
238
+ if (sha === null) return false;
239
+ if (sha === fp.sha) {
240
+ fp.mtimeMs = st.mtimeMs;
241
+ fp.size = st.size;
242
+ this.dirty = true;
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ removeReportFile(relPath) {
248
+ if (!relPath) return;
249
+ try {
250
+ rmSync(path.join(this.cacheDir, relPath), { force: true });
251
+ } catch {
252
+ // best effort
253
+ }
254
+ }
255
+ }
256
+ function sha1(value) {
257
+ return createHash("sha1").update(value).digest("hex");
258
+ }
259
+ // True if any suite or test in a file report carries a "fail" verdict. We only
260
+ // replay passing reports — a failing spec is cheap to re-run and gives fresh
261
+ // failure output rather than a confusing replay.
262
+ export function reportHasFailure(report) {
263
+ const suites = report?.suites;
264
+ if (!Array.isArray(suites)) return false;
265
+ return suites.some(suiteHasFailure);
266
+ }
267
+ function suiteHasFailure(suite) {
268
+ const s = suite;
269
+ if (s?.verdict === "fail") return true;
270
+ if (Array.isArray(s?.tests) && s.tests.some((t) => t?.verdict === "fail")) {
271
+ return true;
272
+ }
273
+ if (Array.isArray(s?.suites) && s.suites.some(suiteHasFailure)) return true;
274
+ return false;
275
+ }
276
+ // Run-scoped cache context, set once by the test orchestrator. `replay` gates
277
+ // Tier 2 (off under snapshot-write flags, on for `cache: "full"`).
278
+ export const cacheStorage = new AsyncLocalStorage();
@@ -22,8 +22,34 @@ import {
22
22
  } from "../util.js";
23
23
  import { persistCrashRecord } from "../crash-store.js";
24
24
  import { BuildWorkerPool } from "../build-worker-pool.js";
25
+ import { resolveSpecFiles, emitSelectorWarnings } from "../selectors.js";
26
+ import { cacheStorage } from "../build-cache.js";
25
27
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
26
28
  export const buildRecorderStorage = new AsyncLocalStorage();
29
+ // The canonical build signature: identical inputs (command, args sans -o, env)
30
+ // yield an identical string. Used by both the cache freshness check inside
31
+ // build() and getBuildReuseInfo() so the two never drift.
32
+ function computeBuildSignature(invocation, signatureEnv) {
33
+ return JSON.stringify({
34
+ command: invocation.command,
35
+ args: stripOutputArgs(invocation.args),
36
+ apiArgs: invocation.apiArgs ? stripOutputArgs(invocation.apiArgs) : [],
37
+ env: sortRecord(signatureEnv),
38
+ });
39
+ }
40
+ // The env that actually affects build output, for the cache signature — only
41
+ // as-test's *declared* env (config + buildOptions env + the AS_TEST_* flags),
42
+ // NOT the inherited process.env. The real build env passed to asc is a superset
43
+ // (it spreads process.env for PATH etc.), but hashing that would let volatile
44
+ // vars like FORCE_COLOR/TERM spuriously invalidate the cache every run.
45
+ function buildSignatureEnv(config, coverageEnabled, modeName) {
46
+ return {
47
+ ...config.env,
48
+ ...config.buildOptions.env,
49
+ AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
50
+ AS_TEST_MODE_NAME: modeName ?? "default",
51
+ };
52
+ }
27
53
  export class BuildFailureError extends Error {
28
54
  constructor(args) {
29
55
  super(args.message);
@@ -67,14 +93,9 @@ export async function build(
67
93
  const pkgRunner = getPkgRunner();
68
94
  const sourceInputPatterns =
69
95
  overrides.kind === "fuzz" ? config.fuzz.input : config.input;
70
- const inputPatterns = resolveInputPatterns(sourceInputPatterns, selectors);
71
- const includePatterns = inputPatterns.filter((p) => !p.startsWith("!"));
72
- const ignorePatterns = inputPatterns
73
- .filter((p) => p.startsWith("!"))
74
- .map((p) => p.slice(1));
75
- const inputFiles = (
76
- await glob(includePatterns, { ignore: ignorePatterns })
77
- ).sort((a, b) => a.localeCompare(b));
96
+ const { files: inputFiles, warnings: selectorWarnings } =
97
+ await resolveSpecFiles(sourceInputPatterns, selectors);
98
+ emitSelectorWarnings(selectorWarnings);
78
99
  await assertNoArtifactCollisions(sourceInputPatterns);
79
100
  warnOnUnknownModeReferences(inputFiles, loadedConfig.modes ?? {});
80
101
  const coverageEnabled = resolveCoverageEnabled(
@@ -87,11 +108,15 @@ export async function build(
87
108
  AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
88
109
  AS_TEST_MODE_NAME: modeName ?? "default",
89
110
  };
111
+ // The cache needs the in-process API build path (only it can record asc's
112
+ // file reads), so when a cache context is active fall through to the direct
113
+ // loop below rather than the child-process serial pool.
90
114
  if (
91
115
  !resolvedConfig &&
92
116
  !process.env.AS_TEST_BUILD_API &&
93
117
  !hasCustomBuildCommand(config) &&
94
- !buildRecorderStorage.getStore()
118
+ !buildRecorderStorage.getStore() &&
119
+ !cacheStorage.getStore()
95
120
  ) {
96
121
  const pool = getSerialBuildWorkerPool();
97
122
  for (const file of inputFiles) {
@@ -118,12 +143,13 @@ export async function build(
118
143
  }
119
144
  return;
120
145
  }
146
+ const cacheCtx = cacheStorage.getStore();
147
+ const canCache = !!cacheCtx && !hasCustomBuildCommand(config);
121
148
  for (const file of inputFiles) {
122
149
  const outFile = path.join(
123
150
  config.outDir,
124
151
  resolveArtifactPath(file, sourceInputPatterns),
125
152
  );
126
- mkdirSync(path.dirname(outFile), { recursive: true });
127
153
  const invocation = getBuildCommand(
128
154
  config,
129
155
  pkgRunner,
@@ -132,8 +158,50 @@ export async function build(
132
158
  modeName,
133
159
  featureToggles,
134
160
  );
161
+ const signature = canCache
162
+ ? computeBuildSignature(
163
+ invocation,
164
+ buildSignatureEnv(config, coverageEnabled, modeName),
165
+ )
166
+ : "";
167
+ // Cache hit: spec + every recorded dependency unchanged and the build
168
+ // signature still matches, so the existing .wasm is current — skip asc.
169
+ if (
170
+ canCache &&
171
+ cacheCtx.cache.isBuildFresh(modeName, file, {
172
+ signature,
173
+ coverageEnabled,
174
+ })
175
+ ) {
176
+ continue;
177
+ }
178
+ mkdirSync(path.dirname(outFile), { recursive: true });
179
+ const reads = new Set();
135
180
  try {
136
- await buildFile(invocation, buildEnv);
181
+ if (canCache) {
182
+ // Capture asc's file reads (the spec's transitive deps) so the next
183
+ // run can tell when any of them changed. Forward to any outer recorder
184
+ // (e.g. the watch loop's dependency-graph recorder) so it still sees
185
+ // reads while the cache is active.
186
+ const outer = buildRecorderStorage.getStore();
187
+ await buildRecorderStorage.run(
188
+ {
189
+ record: (mode, spec, abs) => {
190
+ reads.add(abs);
191
+ outer?.record(mode, spec, abs);
192
+ },
193
+ },
194
+ () => buildFile(invocation, buildEnv),
195
+ );
196
+ cacheCtx.cache.recordBuild(modeName, file, {
197
+ signature,
198
+ outFile,
199
+ deps: reads,
200
+ coverageEnabled,
201
+ });
202
+ } else {
203
+ await buildFile(invocation, buildEnv);
204
+ }
137
205
  } catch (error) {
138
206
  const modeLabel = modeName ?? "default";
139
207
  const stdout = getBuildStdout(error);
@@ -267,20 +335,13 @@ export async function getBuildReuseInfo(
267
335
  config.coverage,
268
336
  featureToggles.coverage,
269
337
  );
270
- const buildEnv = {
271
- ...mode.env,
272
- ...config.buildOptions.env,
273
- AS_TEST_COVERAGE_ENABLED: coverageEnabled ? "1" : "0",
274
- AS_TEST_MODE_NAME: modeName ?? "default",
275
- };
276
338
  return {
277
- signature: JSON.stringify({
278
- command: invocation.command,
279
- args: stripOutputArgs(invocation.args),
280
- apiArgs: invocation.apiArgs ? stripOutputArgs(invocation.apiArgs) : [],
281
- env: sortRecord(buildEnv),
282
- }),
339
+ signature: computeBuildSignature(
340
+ invocation,
341
+ buildSignatureEnv(config, coverageEnabled, modeName),
342
+ ),
283
343
  outFile,
344
+ coverageEnabled,
284
345
  };
285
346
  }
286
347
  function hasCustomBuildCommand(config) {
@@ -540,61 +601,6 @@ async function assertNoArtifactCollisions(configured) {
540
601
  seen.set(artifact, file);
541
602
  }
542
603
  }
543
- function resolveInputPatterns(configured, selectors) {
544
- const configuredInputs = Array.isArray(configured)
545
- ? configured
546
- : [configured];
547
- if (!selectors.length) return configuredInputs;
548
- const patterns = new Set();
549
- for (const selector of expandSelectors(selectors)) {
550
- if (!selector) continue;
551
- if (isBareSuiteSelector(selector)) {
552
- const base = stripSuiteSuffix(selector);
553
- for (const configuredInput of configuredInputs) {
554
- patterns.add(
555
- path.join(path.dirname(configuredInput), `${base}.spec.ts`),
556
- );
557
- }
558
- continue;
559
- }
560
- patterns.add(selector);
561
- }
562
- return [...patterns];
563
- }
564
- function expandSelectors(selectors) {
565
- const expanded = [];
566
- for (const selector of selectors) {
567
- if (!selector) continue;
568
- if (!shouldSplitSelector(selector)) {
569
- expanded.push(selector);
570
- continue;
571
- }
572
- for (const token of selector.split(",")) {
573
- const trimmed = token.trim();
574
- if (!trimmed.length) continue;
575
- expanded.push(trimmed);
576
- }
577
- }
578
- return expanded;
579
- }
580
- function shouldSplitSelector(selector) {
581
- return (
582
- selector.includes(",") &&
583
- !selector.includes("/") &&
584
- !selector.includes("\\") &&
585
- !/[*?[\]{}]/.test(selector)
586
- );
587
- }
588
- function isBareSuiteSelector(selector) {
589
- return (
590
- !selector.includes("/") &&
591
- !selector.includes("\\") &&
592
- !/[*?[\]{}]/.test(selector)
593
- );
594
- }
595
- function stripSuiteSuffix(selector) {
596
- return selector.replace(/\.spec\.ts$/, "").replace(/\.ts$/, "");
597
- }
598
604
  function ensureDeps(config) {
599
605
  if (config.buildOptions.target == "wasi") {
600
606
  if (!resolveWasiShim()) {
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { existsSync, rmSync } from "fs";
3
3
  import * as path from "path";
4
4
  import { applyMode, loadConfig } from "../util.js";
5
+ import { resolveCacheDir } from "../build-cache.js";
5
6
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
6
7
  export async function clean(
7
8
  configPath = DEFAULT_CONFIG_PATH,
@@ -11,6 +12,9 @@ export async function clean(
11
12
  const loadedConfig = loadConfig(configPath, true);
12
13
  const targets = new Map();
13
14
  const ownership = buildOwnershipMap(loadedConfig);
15
+ // The incremental cache (.as-test/cache) is a single global dir, not
16
+ // per-mode, so it is always cleaned whenever `ast clean` runs.
17
+ collectRootTarget(targets, resolveCacheDir(loadedConfig.outDir), "cache");
14
18
  if (fullClean) {
15
19
  collectRootTarget(targets, loadedConfig.outDir, "build");
16
20
  collectRootTarget(targets, loadedConfig.fuzz.crashDir, "crashes");