as-test 1.0.5 → 1.0.7

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,28 @@
1
1
  # Change Log
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 2026-03-31 - v1.0.7
6
+
7
+ ### Coverage
8
+
9
+ - feat: make CLI coverage output easier to scan with a summarized coverage block, per-file breakdown, grouped uncovered points, clickable `file:line:column` locations, trimmed source snippets, aligned gap columns, source-aware labels such as `Function`, `Method`, `Constructor`, `Property`, and `Call`, and coverage-point ignore rules for labels, names, locations, and snippets.
10
+
11
+ ### Fuzzing
12
+
13
+ - feat: allow `fuzz(...)` and `xfuzz(...)` targets to override their own operation count with an optional third argument, so one file can mix short smoke fuzzers and heavier targets without changing the global `fuzz.runs` config.
14
+ - feat: make `--runs` / `--fuzz-runs` accept absolute and relative overrides such as `500`, `1.5x`, `+10%`, and `+100000`, applying them to each fuzzer's effective run count for that command.
15
+
16
+ ## 2026-03-31 - v1.0.6
17
+
18
+ ### Fuzzing
19
+
20
+ - feat: print exact failing fuzz seeds and one-run repro commands on logical fuzz failures, and persist captured `run(...)` inputs in `.as-test/crashes` so side-effectful generators still leave behind replayable failure data.
21
+
22
+ ### Reporting
23
+
24
+ - fix: correct reporter override output behavior.
25
+
3
26
  ## 2026-03-30 - v1.0.5
4
27
 
5
28
  ### CLI
package/README.md CHANGED
@@ -83,6 +83,23 @@ Minimal `as-test.config.json`:
83
83
  }
84
84
  ```
85
85
 
86
+ Coverage point filtering is configurable when you want to ignore known-noisy gaps:
87
+
88
+ ```json
89
+ {
90
+ "coverage": {
91
+ "enabled": true,
92
+ "include": ["assembly/src/**/*.ts"],
93
+ "ignore": {
94
+ "labels": ["Call"],
95
+ "names": ["panic", "serialize"],
96
+ "locations": ["assembly/src/fuzz.ts:38:*"],
97
+ "snippets": ["*message: string*"]
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
86
103
  ## Writing Tests
87
104
 
88
105
  Tests usually live in `assembly/__tests__/*.spec.ts`.
@@ -218,6 +235,23 @@ fuzz("bounded integer addition", (left: i32, right: i32): bool => {
218
235
  });
219
236
  ```
220
237
 
238
+ Pass a third argument to override the operation count for one target without changing the global fuzz config:
239
+
240
+ ```ts
241
+ fuzz("hot path stays stable", (): void => {
242
+ expect(1 + 1).toBe(2);
243
+ }, 250);
244
+ ```
245
+
246
+ You can still override fuzz runs from the CLI when you want to force a different count for the current command:
247
+
248
+ ```bash
249
+ npx ast fuzz --runs 500
250
+ npx ast fuzz --runs 1.5x
251
+ npx ast fuzz --runs +10%
252
+ npx ast fuzz --runs +100000
253
+ ```
254
+
221
255
  If you used `npx ast init` with a fuzzer example, the config is already there. Otherwise, add a `fuzz` block to `as-test.config.json` so `npx ast fuzz` knows what to build:
222
256
 
223
257
  ```json
@@ -231,6 +265,8 @@ If you used `npx ast init` with a fuzzer example, the config is already there. O
231
265
 
232
266
  `ast fuzz` runs fuzz files across the selected modes, reports one result per file, and keeps the final summary separate from the normal test totals. If you want one combined command, use `ast test --fuzz`.
233
267
 
268
+ When a fuzzer fails, `as-test` now prints the exact failing seeds and one-run repro commands such as `ast fuzz ... --seed <seed+n> --runs 1`. Crash records in `.as-test/crashes` also include the captured inputs passed to `run(...)`, which helps when the generator itself has side effects.
269
+
234
270
  Run only fuzzers:
235
271
 
236
272
  ```bash
@@ -121,6 +121,45 @@
121
121
  "type": "string"
122
122
  },
123
123
  "default": []
124
+ },
125
+ "ignore": {
126
+ "type": "object",
127
+ "description": "Ignore specific coverage points by derived label, symbol name, location, or source snippet.",
128
+ "additionalProperties": true,
129
+ "properties": {
130
+ "labels": {
131
+ "type": "array",
132
+ "description": "Ignore all points whose rendered label matches, such as Call, Method, Constructor, Property, or Function.",
133
+ "items": {
134
+ "type": "string"
135
+ },
136
+ "default": []
137
+ },
138
+ "names": {
139
+ "type": "array",
140
+ "description": "Ignore declarations or calls whose extracted symbol name matches a glob pattern.",
141
+ "items": {
142
+ "type": "string"
143
+ },
144
+ "default": []
145
+ },
146
+ "locations": {
147
+ "type": "array",
148
+ "description": "Ignore points whose file:line:column location matches a glob pattern.",
149
+ "items": {
150
+ "type": "string"
151
+ },
152
+ "default": []
153
+ },
154
+ "snippets": {
155
+ "type": "array",
156
+ "description": "Ignore points whose trimmed source snippet matches a glob pattern.",
157
+ "items": {
158
+ "type": "string"
159
+ },
160
+ "default": []
161
+ }
162
+ }
124
163
  }
125
164
  }
126
165
  }
package/assembly/index.ts CHANGED
@@ -169,8 +169,9 @@ export function xit(description: string, callback: () => void): void {
169
169
  export function fuzz<T extends Function>(
170
170
  description: string,
171
171
  callback: T,
172
+ operations: i32 = 0,
172
173
  ): FuzzerBase {
173
- const entry = createFuzzer(description, callback);
174
+ const entry = createFuzzer(description, callback, false, operations);
174
175
  entryFuzzers.push(entry);
175
176
  return entry;
176
177
  }
@@ -178,8 +179,9 @@ export function fuzz<T extends Function>(
178
179
  export function xfuzz<T extends Function>(
179
180
  description: string,
180
181
  callback: T,
182
+ operations: i32 = 0,
181
183
  ): FuzzerBase {
182
- const entry = createFuzzer(description, callback, true);
184
+ const entry = createFuzzer(description, callback, true, operations);
183
185
  entryFuzzers.push(entry);
184
186
  return entry;
185
187
  }
@@ -421,6 +423,8 @@ function containsOnlySuites(values: Suite[]): bool {
421
423
  class FuzzConfig {
422
424
  runs: i32 = 1000;
423
425
  seed: u64 = 1337;
426
+ runsOverrideKind: i32 = 0;
427
+ runsOverrideValue: f64 = 0.0;
424
428
  }
425
429
 
426
430
  class FuzzReport {
@@ -444,7 +448,10 @@ function runFuzzers(): void {
444
448
  for (let i = 0; i < entryFuzzers.length; i++) {
445
449
  const fuzzer = unchecked(entryFuzzers[i]);
446
450
  prepareFuzzIteration();
447
- const result = fuzzer.run(config.seed, config.runs);
451
+ const result = fuzzer.run(
452
+ config.seed,
453
+ resolveFuzzerRuns(fuzzer, config),
454
+ );
448
455
  report.fuzzers.push(result);
449
456
  }
450
457
  sendReport(report.serialize());
@@ -455,9 +462,31 @@ function requestFuzzConfig(): FuzzConfig {
455
462
  const reply = requestHostFuzzConfig();
456
463
  out.runs = reply.runs;
457
464
  out.seed = reply.seed;
465
+ out.runsOverrideKind = reply.runsOverrideKind;
466
+ out.runsOverrideValue = reply.runsOverrideValue;
458
467
  return out;
459
468
  }
460
469
 
470
+ function resolveFuzzerRuns(fuzzer: FuzzerBase, config: FuzzConfig): i32 {
471
+ const baseRuns = fuzzer.runsOr(config.runs);
472
+ const resolved = applyFuzzRunsOverride(
473
+ baseRuns,
474
+ config.runsOverrideKind,
475
+ config.runsOverrideValue,
476
+ );
477
+ return resolved > 0 ? resolved : 1;
478
+ }
479
+
480
+ function applyFuzzRunsOverride(baseRuns: i32, kind: i32, value: f64): i32 {
481
+ if (kind == 1) return <i32>value;
482
+ if (kind == 2) return <i32>Math.round(<f64>baseRuns * value);
483
+ if (kind == 3) return baseRuns + <i32>value;
484
+ if (kind == 4) {
485
+ return baseRuns + <i32>Math.round((<f64>baseRuns * value) / 100.0);
486
+ }
487
+ return baseRuns;
488
+ }
489
+
461
490
  function registerSuite(
462
491
  description: string,
463
492
  callback: () => void,
@@ -1,4 +1,4 @@
1
- import { quote } from "../util/json";
1
+ import { quote, rawOrNull, stringifyValue } from "../util/json";
2
2
 
3
3
  export class StringOptions {
4
4
  charset: string = "ascii";
@@ -186,15 +186,21 @@ export class FuzzSeed {
186
186
  export abstract class FuzzerBase {
187
187
  public name: string;
188
188
  public skipped: bool;
189
- constructor(name: string, skipped: bool = false) {
189
+ public operations: i32;
190
+ constructor(name: string, skipped: bool = false, operations: i32 = 0) {
190
191
  this.name = name;
191
192
  this.skipped = skipped;
193
+ this.operations = operations > 0 ? operations : 0;
192
194
  }
193
195
 
194
196
  generate<T extends Function>(_generator: T): this {
195
197
  return this;
196
198
  }
197
199
 
200
+ runsOr(defaultRuns: i32): i32 {
201
+ return this.operations > 0 ? this.operations : defaultRuns;
202
+ }
203
+
198
204
  abstract run(seed: u64, runs: i32): FuzzerResult;
199
205
  }
200
206
 
@@ -211,6 +217,7 @@ export class FuzzerResult {
211
217
  public failureLeft: string = "";
212
218
  public failureRight: string = "";
213
219
  public failureMessage: string = "";
220
+ public failures: FuzzFailure[] = [];
214
221
 
215
222
  serialize(): string {
216
223
  return (
@@ -238,7 +245,27 @@ export class FuzzerResult {
238
245
  quote(this.failureRight) +
239
246
  ',"message":' +
240
247
  quote(this.failureMessage) +
241
- "}}"
248
+ '},"failures":' +
249
+ serializeFuzzFailures(this.failures) +
250
+ "}"
251
+ );
252
+ }
253
+ }
254
+
255
+ export class FuzzFailure {
256
+ public run: i32 = 0;
257
+ public seed: u64 = 0;
258
+ public input: string = "";
259
+
260
+ serialize(): string {
261
+ return (
262
+ '{"run":' +
263
+ this.run.toString() +
264
+ ',"seed":' +
265
+ this.seed.toString() +
266
+ ',"input":' +
267
+ rawOrNull(this.input) +
268
+ "}"
242
269
  );
243
270
  }
244
271
  }
@@ -329,7 +356,7 @@ function createSkippedResult(name: string): FuzzerResult {
329
356
  return result;
330
357
  }
331
358
 
332
- function recordResult(result: FuzzerResult): void {
359
+ function recordResult(result: FuzzerResult, run: i32, seed: u64): void {
333
360
  if (__as_test_fuzz_failed) {
334
361
  result.failed++;
335
362
  if (!result.failureInstr.length && __as_test_fuzz_failure_instr.length) {
@@ -338,6 +365,11 @@ function recordResult(result: FuzzerResult): void {
338
365
  result.failureRight = __as_test_fuzz_failure_right;
339
366
  result.failureMessage = __as_test_fuzz_failure_message;
340
367
  }
368
+ const failure = new FuzzFailure();
369
+ failure.run = run;
370
+ failure.seed = seed;
371
+ failure.input = __as_test_fuzz_input;
372
+ result.failures.push(failure);
341
373
  } else {
342
374
  result.passed++;
343
375
  }
@@ -351,8 +383,9 @@ export class Fuzzer0<R> extends FuzzerBase {
351
383
  name: string,
352
384
  private callback: () => R,
353
385
  skipped: bool = false,
386
+ operations: i32 = 0,
354
387
  ) {
355
- super(name, skipped);
388
+ super(name, skipped, operations);
356
389
  this.returnsBool = !isVoid<R>();
357
390
  }
358
391
 
@@ -389,7 +422,7 @@ export class Fuzzer0<R> extends FuzzerBase {
389
422
  "fuzz generator must call run() exactly once",
390
423
  );
391
424
  }
392
- recordResult(result);
425
+ recordResult(result, i, seedBase + <u64>i);
393
426
  }
394
427
  __fuzz_callback0 = null;
395
428
  result.timeEnd = performance.now();
@@ -405,8 +438,9 @@ export class Fuzzer1<A, R> extends FuzzerBase {
405
438
  name: string,
406
439
  private callback: (a: A) => R,
407
440
  skipped: bool = false,
441
+ operations: i32 = 0,
408
442
  ) {
409
- super(name, skipped);
443
+ super(name, skipped, operations);
410
444
  this.returnsBool = !isVoid<R>();
411
445
  }
412
446
 
@@ -438,7 +472,10 @@ export class Fuzzer1<A, R> extends FuzzerBase {
438
472
  "fuzzers with arguments must call .generate(...)",
439
473
  );
440
474
  } else {
441
- this.generator(seed, changetype<(a: A) => R>(__fuzz_run1));
475
+ this.generator(seed, (a: A): R => {
476
+ __as_test_fuzz_input = "[" + stringifyValue<A>(a) + "]";
477
+ return changetype<(a: A) => R>(__fuzz_run1)(a);
478
+ });
442
479
  }
443
480
  if (__fuzz_calls != 1) {
444
481
  failFuzzIteration(
@@ -448,7 +485,7 @@ export class Fuzzer1<A, R> extends FuzzerBase {
448
485
  "fuzz generator must call run() exactly once",
449
486
  );
450
487
  }
451
- recordResult(result);
488
+ recordResult(result, i, seedBase + <u64>i);
452
489
  }
453
490
  __fuzz_callback1 = null;
454
491
  result.timeEnd = performance.now();
@@ -465,8 +502,9 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
465
502
  name: string,
466
503
  private callback: (a: A, b: B) => R,
467
504
  skipped: bool = false,
505
+ operations: i32 = 0,
468
506
  ) {
469
- super(name, skipped);
507
+ super(name, skipped, operations);
470
508
  this.returnsBool = !isVoid<R>();
471
509
  }
472
510
 
@@ -500,7 +538,11 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
500
538
  "fuzzers with arguments must call .generate(...)",
501
539
  );
502
540
  } else {
503
- this.generator(seed, changetype<(a: A, b: B) => R>(__fuzz_run2));
541
+ this.generator(seed, (a: A, b: B): R => {
542
+ __as_test_fuzz_input =
543
+ "[" + stringifyValue<A>(a) + "," + stringifyValue<B>(b) + "]";
544
+ return changetype<(a: A, b: B) => R>(__fuzz_run2)(a, b);
545
+ });
504
546
  }
505
547
  if (__fuzz_calls != 1) {
506
548
  failFuzzIteration(
@@ -510,7 +552,7 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
510
552
  "fuzz generator must call run() exactly once",
511
553
  );
512
554
  }
513
- recordResult(result);
555
+ recordResult(result, i, seedBase + <u64>i);
514
556
  }
515
557
  __fuzz_callback2 = null;
516
558
  result.timeEnd = performance.now();
@@ -526,8 +568,9 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
526
568
  name: string,
527
569
  private callback: (a: A, b: B, c: C) => R,
528
570
  skipped: bool = false,
571
+ operations: i32 = 0,
529
572
  ) {
530
- super(name, skipped);
573
+ super(name, skipped, operations);
531
574
  this.returnsBool = !isVoid<R>();
532
575
  }
533
576
 
@@ -564,7 +607,17 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
564
607
  } else {
565
608
  changetype<(seed: FuzzSeed, run: (a: A, b: B, c: C) => R) => void>(
566
609
  this.generator,
567
- )(seed, changetype<(a: A, b: B, c: C) => R>(__fuzz_run3));
610
+ )(seed, (a: A, b: B, c: C): R => {
611
+ __as_test_fuzz_input =
612
+ "[" +
613
+ stringifyValue<A>(a) +
614
+ "," +
615
+ stringifyValue<B>(b) +
616
+ "," +
617
+ stringifyValue<C>(c) +
618
+ "]";
619
+ return changetype<(a: A, b: B, c: C) => R>(__fuzz_run3)(a, b, c);
620
+ });
568
621
  }
569
622
  if (__fuzz_calls != 1) {
570
623
  failFuzzIteration(
@@ -574,7 +627,7 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
574
627
  "fuzz generator must call run() exactly once",
575
628
  );
576
629
  }
577
- recordResult(result);
630
+ recordResult(result, i, seedBase + <u64>i);
578
631
  }
579
632
  __fuzz_callback3 = null;
580
633
  result.timeEnd = performance.now();
@@ -586,16 +639,23 @@ export function createFuzzer<T extends Function>(
586
639
  name: string,
587
640
  callback: T,
588
641
  skipped: bool = false,
642
+ operations: i32 = 0,
589
643
  ): FuzzerBase {
590
644
  const length = callback.length;
591
645
  if (length == 0) {
592
- return new Fuzzer0<usize>(name, changetype<() => usize>(callback), skipped);
646
+ return new Fuzzer0<usize>(
647
+ name,
648
+ changetype<() => usize>(callback),
649
+ skipped,
650
+ operations,
651
+ );
593
652
  }
594
653
  if (length == 1) {
595
654
  return new Fuzzer1<usize, usize>(
596
655
  name,
597
656
  changetype<(a: usize) => usize>(callback),
598
657
  skipped,
658
+ operations,
599
659
  );
600
660
  }
601
661
  if (length == 2) {
@@ -603,6 +663,7 @@ export function createFuzzer<T extends Function>(
603
663
  name,
604
664
  changetype<(a: usize, b: usize) => usize>(callback),
605
665
  skipped,
666
+ operations,
606
667
  );
607
668
  }
608
669
  if (length == 3) {
@@ -610,10 +671,16 @@ export function createFuzzer<T extends Function>(
610
671
  name,
611
672
  changetype<(a: usize, b: usize, c: usize) => usize>(callback),
612
673
  skipped,
674
+ operations,
613
675
  );
614
676
  }
615
677
  panic();
616
- return new Fuzzer0<usize>(name, changetype<() => usize>(callback), skipped);
678
+ return new Fuzzer0<usize>(
679
+ name,
680
+ changetype<() => usize>(callback),
681
+ skipped,
682
+ operations,
683
+ );
617
684
  }
618
685
 
619
686
  function buildAlphabet(options: StringOptions): i32[] {
@@ -694,6 +761,8 @@ function containsFloatValue<T>(values: T[], needle: T): bool {
694
761
  @global export let __as_test_fuzz_failure_right: string = "";
695
762
  // @ts-ignore
696
763
  @global export let __as_test_fuzz_failure_message: string = "";
764
+ // @ts-ignore
765
+ @global export let __as_test_fuzz_input: string = "";
697
766
 
698
767
  export function prepareFuzzIteration(): void {
699
768
  __as_test_fuzz_failed = false;
@@ -701,6 +770,7 @@ export function prepareFuzzIteration(): void {
701
770
  __as_test_fuzz_failure_left = "";
702
771
  __as_test_fuzz_failure_right = "";
703
772
  __as_test_fuzz_failure_message = "";
773
+ __as_test_fuzz_input = "[]";
704
774
  }
705
775
 
706
776
  export function failFuzzIteration(
@@ -721,3 +791,15 @@ export function failFuzzIteration(
721
791
  function panic(): void {
722
792
  unreachable();
723
793
  }
794
+
795
+ function serializeFuzzFailures(values: FuzzFailure[]): string {
796
+ if (!values.length) return "[]";
797
+
798
+ let out = "[";
799
+ for (let i = 0; i < values.length; i++) {
800
+ if (i) out += ",";
801
+ out += unchecked(values[i]).serialize();
802
+ }
803
+ out += "]";
804
+ return out;
805
+ }
@@ -52,6 +52,8 @@ export class SnapshotReply {
52
52
  export class FuzzConfigReply {
53
53
  public runs: i32 = 1000;
54
54
  public seed: u64 = 1337;
55
+ public runsOverrideKind: i32 = 0;
56
+ public runsOverrideValue: f64 = 0.0;
55
57
  }
56
58
 
57
59
  export function sendAssertionFailure(
@@ -141,13 +143,21 @@ export function requestFuzzConfig(): FuzzConfigReply {
141
143
  if (!body.length) {
142
144
  return new FuzzConfigReply();
143
145
  }
144
- const sep = body.indexOf("\n");
145
- if (sep < 0) return new FuzzConfigReply();
146
+ const first = body.indexOf("\n");
147
+ if (first < 0) return new FuzzConfigReply();
146
148
  const reply = new FuzzConfigReply();
147
- const runs = body.slice(0, sep);
148
- const seed = body.slice(sep + 1);
149
+ const second = body.indexOf("\n", first + 1);
150
+ const third = second >= 0 ? body.indexOf("\n", second + 1) : -1;
151
+ const runs = body.slice(0, first);
152
+ const seed =
153
+ second >= 0 ? body.slice(first + 1, second) : body.slice(first + 1);
154
+ const kind =
155
+ second >= 0 && third >= 0 ? body.slice(second + 1, third) : "";
156
+ const value = third >= 0 ? body.slice(third + 1) : "";
149
157
  if (runs.length) reply.runs = I32.parseInt(runs);
150
158
  if (seed.length) reply.seed = U64.parseInt(seed);
159
+ if (kind.length) reply.runsOverrideKind = I32.parseInt(kind);
160
+ if (value.length) reply.runsOverrideValue = parseFloat(value);
151
161
  return reply;
152
162
  }
153
163
 
@@ -29,12 +29,39 @@ export async function fuzz(configPath = DEFAULT_CONFIG_PATH, selectors = [], mod
29
29
  return results;
30
30
  }
31
31
  function resolveFuzzConfig(raw, overrides) {
32
- const config = Object.assign({}, raw, overrides);
32
+ const config = Object.assign({}, raw);
33
+ if (typeof overrides.seed == "number") {
34
+ config.seed = overrides.seed;
35
+ }
36
+ if (typeof overrides.runs == "number") {
37
+ config.runs = overrides.runs;
38
+ }
39
+ config.runsOverrideKind = 0;
40
+ config.runsOverrideValue = 0;
41
+ if (overrides.runsOverride) {
42
+ config.runsOverrideKind = encodeRunsOverrideKind(overrides.runsOverride.kind);
43
+ config.runsOverrideValue = overrides.runsOverride.value;
44
+ if (overrides.runsOverride.kind == "set") {
45
+ config.runs = Math.max(1, Math.round(overrides.runsOverride.value));
46
+ }
47
+ }
33
48
  if (config.target != "bindings") {
34
49
  throw new Error(`fuzz target must be "bindings"; received "${config.target}"`);
35
50
  }
36
51
  return config;
37
52
  }
53
+ function encodeRunsOverrideKind(kind) {
54
+ switch (kind) {
55
+ case "set":
56
+ return 1;
57
+ case "scale":
58
+ return 2;
59
+ case "add":
60
+ return 3;
61
+ case "percent-add":
62
+ return 4;
63
+ }
64
+ }
38
65
  async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStartedAt, buildFinishedAt, buildTime, modeName) {
39
66
  const startedAt = Date.now();
40
67
  const artifact = resolveArtifactFileName(file, duplicateBasenames, modeName);
@@ -48,7 +75,8 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
48
75
  if (type == 0x02) {
49
76
  const event = JSON.parse(payload.toString("utf8"));
50
77
  if (String(event.kind ?? "") == "fuzz:config") {
51
- respond(`${config.runs}\n${config.seed}`);
78
+ const resolved = config;
79
+ respond(`${config.runs}\n${config.seed}\n${resolved.runsOverrideKind ?? 0}\n${resolved.runsOverrideValue ?? 0}`);
52
80
  }
53
81
  else {
54
82
  respond("");
@@ -68,6 +96,7 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
68
96
  const crash = persistCrashRecord(config.crashDir, {
69
97
  kind: "fuzz",
70
98
  file,
99
+ entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
71
100
  mode: modeName ?? "default",
72
101
  seed: config.seed,
73
102
  error: crashMessage,
@@ -94,6 +123,7 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
94
123
  const crash = persistCrashRecord(config.crashDir, {
95
124
  kind: "fuzz",
96
125
  file,
126
+ entryKey: buildFuzzCrashEntryKey(file, modeName ?? "default"),
97
127
  mode: modeName ?? "default",
98
128
  seed: config.seed,
99
129
  error: `missing fuzz report payload from ${path.basename(file)}`,
@@ -115,13 +145,37 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
115
145
  fuzzers: [],
116
146
  };
117
147
  }
148
+ const crashFiles = [];
149
+ for (const fuzzer of report.fuzzers) {
150
+ if (fuzzer.failed <= 0 && fuzzer.crashed <= 0)
151
+ continue;
152
+ const firstFailureSeed = typeof fuzzer.failures?.[0]?.seed == "number"
153
+ ? fuzzer.failures[0].seed
154
+ : config.seed;
155
+ const crash = persistCrashRecord(config.crashDir, {
156
+ kind: "fuzz",
157
+ file,
158
+ entryKey: buildFuzzFailureEntryKey(file, fuzzer.name, modeName ?? "default"),
159
+ mode: modeName ?? "default",
160
+ seed: firstFailureSeed,
161
+ reproCommand: buildFuzzReproCommand(file, firstFailureSeed, modeName ?? "default", 1),
162
+ error: fuzzer.failure?.message ||
163
+ `fuzz failure in ${fuzzer.name} after ${fuzzer.runs} runs`,
164
+ stdout: passthrough.stdout,
165
+ stderr: "",
166
+ failure: fuzzer.failure,
167
+ failures: fuzzer.failures,
168
+ });
169
+ crashFiles.push(crash.jsonPath);
170
+ fuzzer.crashFile = crash.jsonPath;
171
+ }
118
172
  return {
119
173
  file,
120
174
  target: path.basename(file),
121
175
  modeName: modeName ?? "default",
122
176
  runs: report.fuzzers.reduce((sum, item) => sum + item.runs, 0),
123
177
  crashes: report.fuzzers.reduce((sum, item) => sum + item.crashed, 0),
124
- crashFiles: [],
178
+ crashFiles,
125
179
  seed: config.seed,
126
180
  time: Date.now() - startedAt,
127
181
  buildTime,
@@ -130,6 +184,23 @@ async function runFuzzTarget(file, outDir, duplicateBasenames, config, buildStar
130
184
  fuzzers: report.fuzzers,
131
185
  };
132
186
  }
187
+ function buildFuzzReproCommand(file, seed, modeName, runs) {
188
+ const modeArg = modeName != "default" ? ` --mode ${modeName}` : "";
189
+ const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
190
+ return `ast fuzz ${file}${modeArg} --seed ${seed}${runsArg}`;
191
+ }
192
+ function buildFuzzFailureEntryKey(file, name, modeName) {
193
+ return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}.${sanitizeEntryName(name)}`;
194
+ }
195
+ function buildFuzzCrashEntryKey(file, modeName) {
196
+ return `${path.basename(file).replace(/\.ts$/, "")}.${sanitizeEntryName(modeName)}`;
197
+ }
198
+ function sanitizeEntryName(name) {
199
+ return name
200
+ .toLowerCase()
201
+ .replace(/[^a-z0-9]+/g, "-")
202
+ .replace(/^-+|-+$/g, "") || "fuzzer";
203
+ }
133
204
  function captureFrames(onFrame) {
134
205
  const originalWrite = process.stdout.write.bind(process.stdout);
135
206
  const originalRead = typeof process.stdin.read == "function"
@@ -961,7 +961,7 @@ function buildBasicFuzzerExample() {
961
961
  fuzz("basic string fuzzer", (value: string): bool => {
962
962
  expect(value.length >= 0).toBe(true);
963
963
  return value.length <= 24;
964
- }).generate((seed: FuzzSeed, run: (value: string) => bool): void => {
964
+ }, 250).generate((seed: FuzzSeed, run: (value: string) => bool): void => {
965
965
  run(
966
966
  seed.string({
967
967
  charset: "ascii",
@@ -10,6 +10,7 @@ import { buildWebRunnerSource } from "./web-runner-source.js";
10
10
  import { createReporter as createDefaultReporter } from "../reporters/default.js";
11
11
  import { createTapReporter } from "../reporters/tap.js";
12
12
  import { persistCrashRecord } from "../crash-store.js";
13
+ import { describeCoveragePoint } from "../coverage-points.js";
13
14
  const DEFAULT_CONFIG_PATH = path.join(process.cwd(), "./as-test.config.json");
14
15
  class SnapshotStore {
15
16
  constructor(specFile, snapshotDir, duplicateSpecBasenames = new Set()) {
@@ -1091,6 +1092,8 @@ function collectCoverageSummary(reports, enabled, showPoints, coverage) {
1091
1092
  for (const point of report.coverage.points) {
1092
1093
  if (isIgnoredCoverageFile(point.file, coverage))
1093
1094
  continue;
1095
+ if (isIgnoredCoveragePoint(point, coverage))
1096
+ continue;
1094
1097
  const key = `${point.file}::${point.hash}`;
1095
1098
  const existing = uniquePoints.get(key);
1096
1099
  if (!existing) {
@@ -1246,10 +1249,19 @@ function resolveCoverageOptions(raw) {
1246
1249
  includeSpecs: false,
1247
1250
  include: [],
1248
1251
  exclude: [],
1252
+ ignore: {
1253
+ labels: [],
1254
+ names: [],
1255
+ locations: [],
1256
+ snippets: [],
1257
+ },
1249
1258
  };
1250
1259
  }
1251
1260
  if (raw && typeof raw == "object") {
1252
1261
  const obj = raw;
1262
+ const ignore = obj.ignore && typeof obj.ignore == "object" && !Array.isArray(obj.ignore)
1263
+ ? obj.ignore
1264
+ : null;
1253
1265
  return {
1254
1266
  enabled: obj.enabled == null ? false : Boolean(obj.enabled),
1255
1267
  includeSpecs: Boolean(obj.includeSpecs),
@@ -1259,6 +1271,20 @@ function resolveCoverageOptions(raw) {
1259
1271
  exclude: Array.isArray(obj.exclude)
1260
1272
  ? obj.exclude.filter((item) => typeof item == "string")
1261
1273
  : [],
1274
+ ignore: {
1275
+ labels: Array.isArray(ignore?.labels)
1276
+ ? ignore.labels.filter((item) => typeof item == "string")
1277
+ : [],
1278
+ names: Array.isArray(ignore?.names)
1279
+ ? ignore.names.filter((item) => typeof item == "string")
1280
+ : [],
1281
+ locations: Array.isArray(ignore?.locations)
1282
+ ? ignore.locations.filter((item) => typeof item == "string")
1283
+ : [],
1284
+ snippets: Array.isArray(ignore?.snippets)
1285
+ ? ignore.snippets.filter((item) => typeof item == "string")
1286
+ : [],
1287
+ },
1262
1288
  };
1263
1289
  }
1264
1290
  return {
@@ -1266,8 +1292,49 @@ function resolveCoverageOptions(raw) {
1266
1292
  includeSpecs: false,
1267
1293
  include: [],
1268
1294
  exclude: [],
1295
+ ignore: {
1296
+ labels: [],
1297
+ names: [],
1298
+ locations: [],
1299
+ snippets: [],
1300
+ },
1269
1301
  };
1270
1302
  }
1303
+ function isIgnoredCoveragePoint(point, coverage) {
1304
+ const ignore = coverage.ignore;
1305
+ if (!ignore.labels.length &&
1306
+ !ignore.names.length &&
1307
+ !ignore.locations.length &&
1308
+ !ignore.snippets.length) {
1309
+ return false;
1310
+ }
1311
+ const info = describeCoveragePoint(point.file, point.line, point.column, point.type);
1312
+ const location = `${point.file.replace(/\\/g, "/")}:${point.line}:${point.column}`;
1313
+ const label = info.displayType.toLowerCase();
1314
+ const name = info.subjectName?.toLowerCase() ?? "";
1315
+ const snippet = info.visible.toLowerCase();
1316
+ if (ignore.labels.some((pattern) => matchesCoverageTextPattern(label, pattern.toLowerCase()))) {
1317
+ return true;
1318
+ }
1319
+ if (name.length &&
1320
+ ignore.names.some((pattern) => matchesCoverageTextPattern(name, pattern.toLowerCase()))) {
1321
+ return true;
1322
+ }
1323
+ if (ignore.locations.some((pattern) => matchesCoverageTextPattern(location, pattern.replace(/\\/g, "/")))) {
1324
+ return true;
1325
+ }
1326
+ if (snippet.length &&
1327
+ ignore.snippets.some((pattern) => matchesCoverageTextPattern(snippet, pattern.toLowerCase()))) {
1328
+ return true;
1329
+ }
1330
+ return false;
1331
+ }
1332
+ function matchesCoverageTextPattern(value, pattern) {
1333
+ const normalized = pattern.trim();
1334
+ if (!normalized.length)
1335
+ return false;
1336
+ return globPatternToRegExp(normalized).test(value);
1337
+ }
1271
1338
  function compareCoveragePoints(a, b) {
1272
1339
  if (a.line !== b.line)
1273
1340
  return a.line - b.line;
@@ -0,0 +1,173 @@
1
+ import { readFileSync } from "fs";
2
+ import * as path from "path";
3
+ const sourceLineCache = new Map();
4
+ export function describeCoveragePoint(file, line, column, fallbackType) {
5
+ const context = getCoverageSourceContext(file, line, column);
6
+ if (!context) {
7
+ return {
8
+ displayType: fallbackType,
9
+ subjectName: null,
10
+ visible: "",
11
+ focus: 0,
12
+ highlightStart: 0,
13
+ highlightEnd: 0,
14
+ };
15
+ }
16
+ const declaration = detectCoverageDeclaration(context.visible);
17
+ if (declaration) {
18
+ const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(context.visible, context.focus);
19
+ return {
20
+ displayType: declaration.type,
21
+ subjectName: declaration.name,
22
+ visible: context.visible,
23
+ focus: context.focus,
24
+ highlightStart,
25
+ highlightEnd,
26
+ };
27
+ }
28
+ const call = detectCoverageCall(context.visible, context.focus);
29
+ if (call) {
30
+ return {
31
+ displayType: "Call",
32
+ subjectName: call.name,
33
+ visible: context.visible,
34
+ focus: context.focus,
35
+ highlightStart: call.start,
36
+ highlightEnd: call.end,
37
+ };
38
+ }
39
+ const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(context.visible, context.focus);
40
+ return {
41
+ displayType: fallbackType,
42
+ subjectName: null,
43
+ visible: context.visible,
44
+ focus: context.focus,
45
+ highlightStart,
46
+ highlightEnd,
47
+ };
48
+ }
49
+ export function readCoverageSourceLine(file, line) {
50
+ const resolved = path.resolve(process.cwd(), file);
51
+ let lines = sourceLineCache.get(resolved);
52
+ if (lines === undefined) {
53
+ try {
54
+ lines = readFileSync(resolved, "utf8").split(/\r?\n/);
55
+ }
56
+ catch {
57
+ lines = null;
58
+ }
59
+ sourceLineCache.set(resolved, lines);
60
+ }
61
+ if (!lines)
62
+ return "";
63
+ return lines[line - 1] ?? "";
64
+ }
65
+ export function resolveCoverageHighlightSpan(visible, focus) {
66
+ if (!visible.length)
67
+ return [0, 0];
68
+ const index = Math.max(0, Math.min(visible.length - 1, focus));
69
+ if (isCoverageBoundary(visible.charAt(index))) {
70
+ return [index, Math.min(visible.length, index + 1)];
71
+ }
72
+ let start = index;
73
+ let end = index + 1;
74
+ while (start > 0 && !isCoverageBoundary(visible.charAt(start - 1)))
75
+ start--;
76
+ while (end < visible.length && !isCoverageBoundary(visible.charAt(end)))
77
+ end++;
78
+ return [start, end];
79
+ }
80
+ function getCoverageSourceContext(file, line, column) {
81
+ const sourceLine = readCoverageSourceLine(file, line);
82
+ if (!sourceLine)
83
+ return null;
84
+ const expanded = sourceLine.replace(/\t/g, " ");
85
+ const firstNonWhitespace = expanded.search(/\S/);
86
+ if (firstNonWhitespace == -1)
87
+ return null;
88
+ const visible = expanded.slice(firstNonWhitespace).trimEnd();
89
+ if (!visible.length)
90
+ return null;
91
+ const focus = Math.max(0, Math.min(visible.length - 1, Math.max(0, column - 1 - firstNonWhitespace)));
92
+ return { visible, focus };
93
+ }
94
+ function detectCoverageDeclaration(visible) {
95
+ const trimmed = visible.trim();
96
+ if (!trimmed.length)
97
+ return null;
98
+ let match = trimmed.match(/^(?:export\s+)?function\s+([A-Za-z_]\w*)(?:<[^>]+>)?\s*\(/);
99
+ if (match)
100
+ return { type: "Function", name: match[1] ?? null };
101
+ if (trimmed.startsWith("constructor(") ||
102
+ /^(?:public\s+|private\s+|protected\s+)constructor\s*\(/.test(trimmed)) {
103
+ return { type: "Constructor", name: "constructor" };
104
+ }
105
+ match = trimmed.match(/^(?:export\s+)?(?:public\s+|private\s+|protected\s+)?(?:static\s+)?([A-Za-z_]\w*)(?:<[^>]+>)?\([^)]*\)\s*:\s*[^{=]+[{]?$/);
106
+ if (match)
107
+ return { type: "Method", name: match[1] ?? null };
108
+ match = trimmed.match(/^(?:public\s+|private\s+|protected\s+)?(?:readonly\s+)?([A-Za-z_]\w*)(?:<[^>]+>)?\s*:\s*[^=;{]+(?:=.*)?;?$/);
109
+ if (match)
110
+ return { type: "Property", name: match[1] ?? null };
111
+ if (/^(?:export\s+)?class\b/.test(trimmed)) {
112
+ match = trimmed.match(/^(?:export\s+)?class\s+([A-Za-z_]\w*)/);
113
+ return { type: "Class", name: match?.[1] ?? null };
114
+ }
115
+ if (/^(?:export\s+)?enum\b/.test(trimmed)) {
116
+ match = trimmed.match(/^(?:export\s+)?enum\s+([A-Za-z_]\w*)/);
117
+ return { type: "Enum", name: match?.[1] ?? null };
118
+ }
119
+ if (/^(?:export\s+)?interface\b/.test(trimmed)) {
120
+ match = trimmed.match(/^(?:export\s+)?interface\s+([A-Za-z_]\w*)/);
121
+ return { type: "Interface", name: match?.[1] ?? null };
122
+ }
123
+ if (/^(?:export\s+)?namespace\b/.test(trimmed)) {
124
+ match = trimmed.match(/^(?:export\s+)?namespace\s+([A-Za-z_]\w*)/);
125
+ return { type: "Namespace", name: match?.[1] ?? null };
126
+ }
127
+ if (/^(?:const|let|var)\b/.test(trimmed)) {
128
+ match = trimmed.match(/^(?:const|let|var)\s+([A-Za-z_]\w*)/);
129
+ return { type: "Variable", name: match?.[1] ?? null };
130
+ }
131
+ return null;
132
+ }
133
+ function detectCoverageCall(visible, focus) {
134
+ const matches = [...visible.matchAll(/\b([A-Za-z_]\w*)(?:<[^>()]+>)?\s*\(/g)];
135
+ if (!matches.length)
136
+ return null;
137
+ let bestDistance = Number.POSITIVE_INFINITY;
138
+ let bestMatch = null;
139
+ for (const match of matches) {
140
+ const start = match.index ?? -1;
141
+ if (start == -1)
142
+ continue;
143
+ const end = start + match[0].length;
144
+ const distance = focus < start ? start - focus : focus >= end ? focus - end + 1 : 0;
145
+ if (distance < bestDistance) {
146
+ bestDistance = distance;
147
+ bestMatch = match;
148
+ }
149
+ }
150
+ if (!bestMatch)
151
+ return null;
152
+ const name = bestMatch[1] ?? null;
153
+ if (name == "if" ||
154
+ name == "for" ||
155
+ name == "while" ||
156
+ name == "switch" ||
157
+ name == "return" ||
158
+ name == "function") {
159
+ return null;
160
+ }
161
+ if (bestDistance > Math.max(12, Math.floor(visible.length / 3))) {
162
+ return null;
163
+ }
164
+ const start = bestMatch.index ?? 0;
165
+ return {
166
+ name,
167
+ start,
168
+ end: start + (name?.length ?? 1),
169
+ };
170
+ }
171
+ function isCoverageBoundary(ch) {
172
+ return /[\s()[\]{}.,;:+\-*/%&|^!?=<>]/.test(ch);
173
+ }
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
2
  import * as path from "path";
3
3
  export function persistCrashRecord(rootDir, record) {
4
- const entry = crashEntryKey(record.file);
4
+ const entry = record.entryKey?.length ? record.entryKey : crashEntryKey(record.file);
5
5
  const dir = path.resolve(process.cwd(), rootDir);
6
6
  mkdirSync(dir, { recursive: true });
7
7
  const jsonPath = path.join(dir, `${entry}.json`);
@@ -53,6 +53,17 @@ function buildCrashLog(payload) {
53
53
  lines.push(`message: ${payload.failure.message}`);
54
54
  }
55
55
  }
56
+ if (payload.failures?.length) {
57
+ lines.push("");
58
+ lines.push("[fuzz-failures]");
59
+ for (const failure of payload.failures) {
60
+ lines.push(`run: ${failure.run}`);
61
+ lines.push(`seed: ${failure.seed}`);
62
+ if (failure.input)
63
+ lines.push(`input: ${JSON.stringify(failure.input)}`);
64
+ lines.push("");
65
+ }
66
+ }
56
67
  lines.push("");
57
68
  lines.push("[stdout]");
58
69
  lines.push(payload.stdout ?? "");
package/bin/index.js CHANGED
@@ -281,7 +281,7 @@ function printCommandHelp(command) {
281
281
  process.stdout.write(" --enable <feature> Enable feature (coverage|try-as)\n");
282
282
  process.stdout.write(" --disable <feature> Disable feature (coverage|try-as)\n");
283
283
  process.stdout.write(" --fuzz Run fuzz targets after the normal test pass\n");
284
- process.stdout.write(" --fuzz-runs <n> Override fuzz iteration count for this run\n");
284
+ process.stdout.write(" --fuzz-runs <value> Override fuzz iteration count, e.g. 500, 1.5x, +10%, +100000\n");
285
285
  process.stdout.write(" --fuzz-seed <n> Override fuzz seed for this run\n");
286
286
  process.stdout.write(" --parallel Run files through an ordered worker pool using an automatic worker count\n");
287
287
  process.stdout.write(" --jobs <n> Run files through an ordered worker pool\n");
@@ -302,7 +302,7 @@ function printCommandHelp(command) {
302
302
  process.stdout.write(chalk.bold("Flags:\n"));
303
303
  process.stdout.write(" --config <path> Use a specific config file\n");
304
304
  process.stdout.write(" --mode <name[,name...]> Run one or multiple named config modes\n");
305
- process.stdout.write(" --runs <n> Override fuzz iteration count\n");
305
+ process.stdout.write(" --runs <value> Override fuzz iteration count, e.g. 500, 1.5x, +10%, +100000\n");
306
306
  process.stdout.write(" --seed <n> Override fuzz seed\n");
307
307
  process.stdout.write(" --jobs <n> Run files through an ordered worker pool\n");
308
308
  process.stdout.write(" --build-jobs <n> Limit concurrent build tasks (defaults to --jobs)\n");
@@ -482,9 +482,10 @@ function resolveFuzzOverrides(rawArgs, command) {
482
482
  runs: "--fuzz-runs",
483
483
  seed: "--fuzz-seed",
484
484
  };
485
- const runs = parseNumberFlag(rawArgs, i, direct.runs);
485
+ const runs = parseFuzzRunsFlag(rawArgs, i, direct.runs);
486
486
  if (runs) {
487
- out.runs = runs.number;
487
+ out.runs = runs.absoluteRuns;
488
+ out.runsOverride = runs.override;
488
489
  if (runs.consumeNext)
489
490
  i++;
490
491
  continue;
@@ -499,6 +500,63 @@ function resolveFuzzOverrides(rawArgs, command) {
499
500
  }
500
501
  return out;
501
502
  }
503
+ function parseFuzzRunsFlag(rawArgs, index, flag) {
504
+ const arg = rawArgs[index];
505
+ let value = "";
506
+ let consumeNext = false;
507
+ if (arg == flag) {
508
+ const next = rawArgs[index + 1];
509
+ if (!next || !next.length) {
510
+ throw new Error(`${flag} requires a value such as 500, 1.5x, +10%, or +100000`);
511
+ }
512
+ value = next;
513
+ consumeNext = true;
514
+ }
515
+ else if (arg.startsWith(`${flag}=`)) {
516
+ value = arg.slice(flag.length + 1);
517
+ if (!value.length) {
518
+ throw new Error(`${flag} requires a value such as 500, 1.5x, +10%, or +100000`);
519
+ }
520
+ }
521
+ else {
522
+ return null;
523
+ }
524
+ const parsed = parseFuzzRunsValue(flag, value.trim());
525
+ return {
526
+ key: flag,
527
+ absoluteRuns: parsed.kind == "set" ? parsed.value : undefined,
528
+ override: parsed,
529
+ consumeNext,
530
+ };
531
+ }
532
+ function parseFuzzRunsValue(flag, value) {
533
+ if (/^\d+$/.test(value)) {
534
+ const parsed = parseIntegerFlag(flag, value);
535
+ return { kind: "set", value: parsed };
536
+ }
537
+ if (/^[+-]\d+$/.test(value)) {
538
+ const delta = Number(value);
539
+ if (!Number.isFinite(delta) || !Number.isInteger(delta)) {
540
+ throw new Error(`${flag} additive run override must be an integer`);
541
+ }
542
+ return { kind: "add", value: delta };
543
+ }
544
+ if (/^\d+(?:\.\d+)?x$/i.test(value)) {
545
+ const factor = Number(value.slice(0, -1));
546
+ if (!Number.isFinite(factor) || factor <= 0) {
547
+ throw new Error(`${flag} multiplier must be greater than 0`);
548
+ }
549
+ return { kind: "scale", value: factor };
550
+ }
551
+ if (/^[+-]\d+(?:\.\d+)?%$/.test(value)) {
552
+ const percent = Number(value.slice(0, -1));
553
+ if (!Number.isFinite(percent)) {
554
+ throw new Error(`${flag} percentage must be numeric`);
555
+ }
556
+ return { kind: "percent-add", value: percent };
557
+ }
558
+ throw new Error(`${flag} must be a run count or expression such as 500, 1.5x, +10%, or +100000`);
559
+ }
502
560
  function resolveListFlags(rawArgs, command) {
503
561
  const out = {
504
562
  list: false,
@@ -3,6 +3,7 @@ import { diff } from "typer-diff";
3
3
  import { readFileSync } from "fs";
4
4
  import * as path from "path";
5
5
  import { formatTime } from "../util.js";
6
+ import { describeCoveragePoint, readCoverageSourceLine, resolveCoverageHighlightSpan, } from "../coverage-points.js";
6
7
  export const createReporter = (context) => {
7
8
  return new DefaultReporter(context);
8
9
  };
@@ -360,7 +361,19 @@ function renderFailedFuzzers(results) {
360
361
  console.log(chalk.dim(`Runs: ${fuzzer.passed + fuzzer.failed + fuzzer.crashed} completed (${fuzzer.passed} passed, ${fuzzer.failed} failed, ${fuzzer.crashed} crashed)`));
361
362
  console.log(chalk.dim(`Repro: ${repro}`));
362
363
  console.log(chalk.dim(`Seed: ${modeResult.seed}`));
363
- if (modeResult.crashFiles.length) {
364
+ if (fuzzer.failures?.length) {
365
+ console.log(chalk.dim(`Failing seeds: ${formatFailingSeeds(fuzzer)}`));
366
+ for (const failure of fuzzer.failures) {
367
+ console.log(chalk.dim(`Repro ${failure.run + 1}: ${buildFuzzReproCommand(relativeFile, failure.seed, modeResult.modeName, 1)}`));
368
+ if (failure.input) {
369
+ console.log(chalk.dim(`Input ${failure.run + 1}: ${JSON.stringify(failure.input)}`));
370
+ }
371
+ }
372
+ }
373
+ if (fuzzer.crashFile?.length) {
374
+ console.log(chalk.dim(`Crash: ${fuzzer.crashFile}`));
375
+ }
376
+ else if (modeResult.crashFiles.length) {
364
377
  console.log(chalk.dim(`Crash: ${modeResult.crashFiles[0]}`));
365
378
  }
366
379
  console.log("");
@@ -392,9 +405,13 @@ function averageFuzzModeTime(results) {
392
405
  return 0;
393
406
  return results.reduce((sum, result) => sum + result.time, 0) / results.length;
394
407
  }
395
- function buildFuzzReproCommand(file, seed, modeName) {
408
+ function buildFuzzReproCommand(file, seed, modeName, runs) {
396
409
  const modeArg = modeName != "default" ? ` --mode ${modeName}` : "";
397
- return `ast fuzz ${file}${modeArg} --seed ${seed}`;
410
+ const runsArg = typeof runs == "number" ? ` --runs ${runs}` : "";
411
+ return `ast fuzz ${file}${modeArg} --seed ${seed}${runsArg}`;
412
+ }
413
+ function formatFailingSeeds(fuzzer) {
414
+ return (fuzzer.failures ?? []).map((failure) => String(failure.seed)).join(", ");
398
415
  }
399
416
  function toRelativeResultPath(file) {
400
417
  const relative = path.relative(process.cwd(), path.resolve(process.cwd(), file));
@@ -617,21 +634,60 @@ function renderSummaryLine(label, summary, layout = {
617
634
  process.stdout.write(totalText.padStart(layout.totalWidth) + "\n");
618
635
  }
619
636
  function renderCoverageSummary(summary) {
637
+ console.log("");
638
+ console.log(chalk.bold("Coverage"));
639
+ if (!summary.files.length || summary.total <= 0) {
640
+ console.log(` ${chalk.dim("No eligible source files were tracked for coverage.")}`);
641
+ return;
642
+ }
620
643
  const pct = summary.total
621
644
  ? ((summary.covered * 100) / summary.total).toFixed(2)
622
645
  : "100.00";
646
+ const missingLabel = summary.uncovered == 1 ? "1 point missing" : `${summary.uncovered} points missing`;
647
+ const fileLabel = summary.files.length == 1 ? "1 file" : `${summary.files.length} files`;
623
648
  const color = Number(pct) >= 90
624
649
  ? chalk.greenBright
625
650
  : Number(pct) >= 75
626
651
  ? chalk.yellowBright
627
652
  : chalk.redBright;
628
- console.log("");
629
- console.log(`${chalk.bold("Coverage:")} ${color(pct + "%")} ${chalk.dim(`(${summary.covered}/${summary.total} points, ${summary.uncovered} uncovered)`)}`);
653
+ console.log(` ${color(pct + "%")} ${renderCoverageBar(summary.percent)} ${chalk.dim(`(${summary.covered}/${summary.total} covered, ${missingLabel}, ${fileLabel})`)}`);
654
+ const ranked = [...summary.files].sort((a, b) => {
655
+ if (a.percent != b.percent)
656
+ return a.percent - b.percent;
657
+ if (a.uncovered != b.uncovered)
658
+ return b.uncovered - a.uncovered;
659
+ return a.file.localeCompare(b.file);
660
+ });
661
+ console.log(chalk.bold(" File Breakdown"));
662
+ for (const file of ranked.slice(0, 8)) {
663
+ const filePct = file.total
664
+ ? ((file.covered * 100) / file.total).toFixed(2)
665
+ : "100.00";
666
+ const fileColor = Number(filePct) >= 90
667
+ ? chalk.greenBright
668
+ : Number(filePct) >= 75
669
+ ? chalk.yellowBright
670
+ : chalk.redBright;
671
+ const suffix = file.uncovered > 0
672
+ ? `${file.uncovered} missing`
673
+ : "fully covered";
674
+ console.log(` ${fileColor(filePct.padStart(6) + "%")} ${toRelativeResultPath(file.file).padEnd(36)} ${chalk.dim(`${file.covered}/${file.total} covered, ${suffix}`)}`);
675
+ }
676
+ if (ranked.length > 8) {
677
+ console.log(chalk.dim(` ... ${ranked.length - 8} more files`));
678
+ }
630
679
  }
631
680
  function renderCoveragePoints(files) {
632
681
  console.log("");
633
- console.log(chalk.bold("Coverage Points:"));
682
+ console.log(chalk.bold("Coverage Gaps"));
634
683
  const sortedFiles = [...files].sort((a, b) => a.file.localeCompare(b.file));
684
+ const missingPoints = sortedFiles.flatMap((file) => file.points
685
+ .filter((point) => !point.executed)
686
+ .map((point) => ({
687
+ ...point,
688
+ displayType: describeCoveragePoint(point.file, point.line, point.column, point.type).displayType,
689
+ })));
690
+ const layout = createCoverageGapLayout(missingPoints);
635
691
  for (const file of sortedFiles) {
636
692
  const points = [...file.points].sort((a, b) => {
637
693
  if (a.line != b.line)
@@ -640,10 +696,69 @@ function renderCoveragePoints(files) {
640
696
  return a.column - b.column;
641
697
  return a.type.localeCompare(b.type);
642
698
  });
699
+ const missing = points.filter((point) => !point.executed);
700
+ if (!missing.length)
701
+ continue;
702
+ console.log(` ${chalk.bold(toRelativeResultPath(file.file))} ${chalk.dim(`(${missing.length} uncovered)`)}`);
643
703
  for (const point of points) {
644
704
  if (point.executed)
645
705
  continue;
646
- console.log(`${chalk.bgRed(" MISS ")} ${chalk.dim(`${point.file}:${point.line}:${point.column}`)} ${chalk.dim(point.type)}`);
706
+ const location = `${toRelativeResultPath(point.file)}:${point.line}:${point.column}`;
707
+ const snippet = formatCoverageSnippet(point.file, point.line, point.column);
708
+ const typeLabel = describeCoveragePoint(point.file, point.line, point.column, point.type).displayType.padEnd(layout.typeWidth + 4);
709
+ const locationLabel = location.padEnd(layout.locationWidth + 4);
710
+ console.log(` ${chalk.red("x")} ${chalk.dim(typeLabel)}${chalk.dim(locationLabel)}${snippet}`);
647
711
  }
648
712
  }
649
713
  }
714
+ function renderCoverageBar(percent) {
715
+ const slots = 12;
716
+ const filled = Math.max(0, Math.min(slots, Math.round((Math.max(0, Math.min(100, percent)) / 100) * slots)));
717
+ return `[${"=".repeat(filled)}${"-".repeat(slots - filled)}]`;
718
+ }
719
+ function createCoverageGapLayout(points) {
720
+ return {
721
+ typeWidth: Math.max(...points.map((point) => point.displayType.length), 5),
722
+ locationWidth: Math.max(...points.map((point) => `${toRelativeResultPath(point.file)}:${point.line}:${point.column}`.length), 1),
723
+ };
724
+ }
725
+ function formatCoverageSnippet(file, line, column) {
726
+ const sourceLine = readCoverageSourceLine(file, line);
727
+ if (!sourceLine)
728
+ return "";
729
+ const expanded = sourceLine.replace(/\t/g, " ");
730
+ const firstNonWhitespace = expanded.search(/\S/);
731
+ if (firstNonWhitespace == -1)
732
+ return "";
733
+ const visible = expanded.slice(firstNonWhitespace).trimEnd();
734
+ if (!visible.length)
735
+ return "";
736
+ const maxWidth = 72;
737
+ const focus = Math.max(0, Math.min(visible.length - 1, Math.max(0, column - 1 - firstNonWhitespace)));
738
+ if (visible.length <= maxWidth) {
739
+ return styleCoverageSnippetWindow(visible, 0, visible.length, focus);
740
+ }
741
+ const start = Math.max(0, Math.min(visible.length - maxWidth, focus - Math.floor(maxWidth / 2)));
742
+ const end = Math.min(visible.length, start + maxWidth);
743
+ return styleCoverageSnippetWindow(visible, start, end, focus);
744
+ }
745
+ function styleCoverageSnippetWindow(visible, start, end, focus) {
746
+ const prefix = start > 0 ? "..." : "";
747
+ const suffix = end < visible.length ? "..." : "";
748
+ const slice = visible.slice(start, end);
749
+ const localFocus = Math.max(0, Math.min(slice.length - 1, focus - start));
750
+ const [highlightStart, highlightEnd] = resolveCoverageHighlightSpan(visible, focus);
751
+ const localStart = Math.max(0, Math.min(slice.length, highlightStart - start));
752
+ const localEnd = Math.max(localStart + 1, Math.min(slice.length, highlightEnd - start));
753
+ if (!slice.length)
754
+ return "";
755
+ if (localStart >= slice.length) {
756
+ return chalk.dim(`${prefix}${slice}${suffix}`);
757
+ }
758
+ const head = slice.slice(0, localStart);
759
+ const body = slice.slice(localStart, localEnd || localStart + 1);
760
+ const tail = slice.slice(localEnd || localStart + 1);
761
+ return (chalk.dim(prefix + head) +
762
+ chalk.dim.underline(body.length ? body : slice.charAt(localFocus)) +
763
+ chalk.dim(tail + suffix));
764
+ }
@@ -221,10 +221,13 @@ function buildFuzzerFailureMessage(result, fuzzer) {
221
221
  if (fuzzer.crashed > 0 || result.crashes > 0) {
222
222
  return buildFuzzMessage(result.runs, result.seed, result.crashFiles[0]);
223
223
  }
224
+ const failureSeeds = fuzzer.failures?.length
225
+ ? `, failing seeds ${fuzzer.failures.map((failure) => failure.seed).join(", ")}`
226
+ : "";
224
227
  if (fuzzer.failure?.message?.length) {
225
- return `${fuzzer.failure.message} (runs ${fuzzer.runs}, seed ${result.seed})`;
228
+ return `${fuzzer.failure.message} (runs ${fuzzer.runs}, seed ${result.seed}${failureSeeds})`;
226
229
  }
227
- return `fuzz failed after ${fuzzer.runs} runs (seed ${result.seed})`;
230
+ return `fuzz failed after ${fuzzer.runs} runs (seed ${result.seed}${failureSeeds})`;
228
231
  }
229
232
  function buildFuzzMessage(runs, seed, crashFile) {
230
233
  const crashSuffix = crashFile?.length ? `, crash ${crashFile}` : "";
package/bin/types.js CHANGED
@@ -21,6 +21,15 @@ export class CoverageOptions {
21
21
  this.includeSpecs = false;
22
22
  this.include = [];
23
23
  this.exclude = [];
24
+ this.ignore = new CoverageIgnoreOptions();
25
+ }
26
+ }
27
+ export class CoverageIgnoreOptions {
28
+ constructor() {
29
+ this.labels = [];
30
+ this.names = [];
31
+ this.locations = [];
32
+ this.snippets = [];
24
33
  }
25
34
  }
26
35
  export class Suite {
package/bin/util.js CHANGED
@@ -340,6 +340,22 @@ function validateCoverageValue(value, path, issues) {
340
340
  }
341
341
  validateStringArrayField(obj, "include", path, issues);
342
342
  validateStringArrayField(obj, "exclude", path, issues);
343
+ if ("ignore" in obj && obj.ignore != undefined) {
344
+ if (!obj.ignore || typeof obj.ignore != "object" || Array.isArray(obj.ignore)) {
345
+ issues.push({
346
+ path: `${path}.ignore`,
347
+ message: "must be an object",
348
+ fix: 'set "ignore" to an object such as { "labels": ["Call"], "names": ["panic"] }',
349
+ });
350
+ }
351
+ else {
352
+ const ignore = obj.ignore;
353
+ validateStringArrayField(ignore, "labels", `${path}.ignore`, issues);
354
+ validateStringArrayField(ignore, "names", `${path}.ignore`, issues);
355
+ validateStringArrayField(ignore, "locations", `${path}.ignore`, issues);
356
+ validateStringArrayField(ignore, "snippets", `${path}.ignore`, issues);
357
+ }
358
+ }
343
359
  }
344
360
  function validateStringArrayField(raw, key, pathPrefix, issues) {
345
361
  if (!(key in raw) || raw[key] == undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "as-test",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "author": "Jairus Tanaka",
5
5
  "repository": {
6
6
  "type": "git",