as-test 1.0.6 → 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,18 @@
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
+
3
16
  ## 2026-03-31 - v1.0.6
4
17
 
5
18
  ### Fuzzing
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
@@ -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,
@@ -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
 
@@ -377,8 +383,9 @@ export class Fuzzer0<R> extends FuzzerBase {
377
383
  name: string,
378
384
  private callback: () => R,
379
385
  skipped: bool = false,
386
+ operations: i32 = 0,
380
387
  ) {
381
- super(name, skipped);
388
+ super(name, skipped, operations);
382
389
  this.returnsBool = !isVoid<R>();
383
390
  }
384
391
 
@@ -431,8 +438,9 @@ export class Fuzzer1<A, R> extends FuzzerBase {
431
438
  name: string,
432
439
  private callback: (a: A) => R,
433
440
  skipped: bool = false,
441
+ operations: i32 = 0,
434
442
  ) {
435
- super(name, skipped);
443
+ super(name, skipped, operations);
436
444
  this.returnsBool = !isVoid<R>();
437
445
  }
438
446
 
@@ -494,8 +502,9 @@ export class Fuzzer2<A, B, R> extends FuzzerBase {
494
502
  name: string,
495
503
  private callback: (a: A, b: B) => R,
496
504
  skipped: bool = false,
505
+ operations: i32 = 0,
497
506
  ) {
498
- super(name, skipped);
507
+ super(name, skipped, operations);
499
508
  this.returnsBool = !isVoid<R>();
500
509
  }
501
510
 
@@ -559,8 +568,9 @@ export class Fuzzer3<A, B, C, R> extends FuzzerBase {
559
568
  name: string,
560
569
  private callback: (a: A, b: B, c: C) => R,
561
570
  skipped: bool = false,
571
+ operations: i32 = 0,
562
572
  ) {
563
- super(name, skipped);
573
+ super(name, skipped, operations);
564
574
  this.returnsBool = !isVoid<R>();
565
575
  }
566
576
 
@@ -629,16 +639,23 @@ export function createFuzzer<T extends Function>(
629
639
  name: string,
630
640
  callback: T,
631
641
  skipped: bool = false,
642
+ operations: i32 = 0,
632
643
  ): FuzzerBase {
633
644
  const length = callback.length;
634
645
  if (length == 0) {
635
- 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
+ );
636
652
  }
637
653
  if (length == 1) {
638
654
  return new Fuzzer1<usize, usize>(
639
655
  name,
640
656
  changetype<(a: usize) => usize>(callback),
641
657
  skipped,
658
+ operations,
642
659
  );
643
660
  }
644
661
  if (length == 2) {
@@ -646,6 +663,7 @@ export function createFuzzer<T extends Function>(
646
663
  name,
647
664
  changetype<(a: usize, b: usize) => usize>(callback),
648
665
  skipped,
666
+ operations,
649
667
  );
650
668
  }
651
669
  if (length == 3) {
@@ -653,10 +671,16 @@ export function createFuzzer<T extends Function>(
653
671
  name,
654
672
  changetype<(a: usize, b: usize, c: usize) => usize>(callback),
655
673
  skipped,
674
+ operations,
656
675
  );
657
676
  }
658
677
  panic();
659
- 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
+ );
660
684
  }
661
685
 
662
686
  function buildAlphabet(options: StringOptions): i32[] {
@@ -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("");
@@ -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
+ }
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
  };
@@ -633,21 +634,60 @@ function renderSummaryLine(label, summary, layout = {
633
634
  process.stdout.write(totalText.padStart(layout.totalWidth) + "\n");
634
635
  }
635
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
+ }
636
643
  const pct = summary.total
637
644
  ? ((summary.covered * 100) / summary.total).toFixed(2)
638
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`;
639
648
  const color = Number(pct) >= 90
640
649
  ? chalk.greenBright
641
650
  : Number(pct) >= 75
642
651
  ? chalk.yellowBright
643
652
  : chalk.redBright;
644
- console.log("");
645
- 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
+ }
646
679
  }
647
680
  function renderCoveragePoints(files) {
648
681
  console.log("");
649
- console.log(chalk.bold("Coverage Points:"));
682
+ console.log(chalk.bold("Coverage Gaps"));
650
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);
651
691
  for (const file of sortedFiles) {
652
692
  const points = [...file.points].sort((a, b) => {
653
693
  if (a.line != b.line)
@@ -656,10 +696,69 @@ function renderCoveragePoints(files) {
656
696
  return a.column - b.column;
657
697
  return a.type.localeCompare(b.type);
658
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)`)}`);
659
703
  for (const point of points) {
660
704
  if (point.executed)
661
705
  continue;
662
- 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}`);
663
711
  }
664
712
  }
665
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
+ }
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.6",
3
+ "version": "1.0.7",
4
4
  "author": "Jairus Tanaka",
5
5
  "repository": {
6
6
  "type": "git",