coverage-check 0.7.0 → 0.7.1

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/README.md CHANGED
@@ -51,12 +51,29 @@ The `--suite` flag on `check` tells the tool to use fresh `--artifacts` for the
51
51
  **S3 key layout:**
52
52
 
53
53
  ```text
54
- <prefix>/<suite>/sha/<sha>/lcov.info # payload
55
- <prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer: { "sha": "...", "timestamp": "..." }
54
+ <prefix>/<suite>/sha/<sha>/lcov.info.gz # gzip payload for new versioned writes
55
+ <prefix>/<suite>/branch/<encoded-branch>/latest.json # pointer with sha, payloadKey, encoding, byte counts, timestamp
56
56
  ```
57
57
 
58
58
  S3-backed stores need `s3:PutObject` for writes and `s3:GetObject` for reading branch pointers and baselines. The pointer reader also checks the previous unencoded pointer key (for example `branch/main/latest.json`) so stores written before branch-name encoding remain readable.
59
59
 
60
+ Versioned S3 writes (`store-put --sha ... --branch ...`) gzip the LCOV payload and write pointer
61
+ metadata with `payloadKey`, `encoding`, `rawBytes`, and `storedBytes`. Existing raw
62
+ `sha/<sha>/lcov.info` payloads and legacy `<suite>/lcov.info` payloads remain readable. Legacy
63
+ writes without `--sha`/`--branch` keep the old raw `<suite>/lcov.info` layout.
64
+
65
+ Every S3 operation logs a concise diagnostic line to stderr with the operation name, bucket, key,
66
+ elapsed time, and byte counts where applicable. Use these lines to distinguish payload writes,
67
+ branch-pointer reads, and branch-pointer writes when CI storage stalls.
68
+
69
+ S3 request bounds are configurable with environment variables:
70
+
71
+ | Variable | Default | Purpose |
72
+ | ----------------------------------------- | ------- | ---------------------------------------- |
73
+ | `COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS` | `5000` | Socket connection timeout for S3 calls |
74
+ | `COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS` | `30000` | Whole-request timeout for S3 calls |
75
+ | `COVERAGE_CHECK_S3_MAX_ATTEMPTS` | `2` | AWS SDK attempt count, including retries |
76
+
60
77
  ### Suite store with filesystem
61
78
 
62
79
  For local development or simpler deployments:
@@ -0,0 +1,9 @@
1
+ export type ClientLike = {
2
+ send(cmd: object): Promise<unknown>;
3
+ };
4
+ export type S3OperationDetails = {
5
+ rawBytes?: number;
6
+ storedBytes?: number;
7
+ };
8
+ export declare function createS3Client(region?: string): ClientLike;
9
+ export declare function sendS3(client: ClientLike, bucket: string, operation: string, key: string, command: object, details?: S3OperationDetails): Promise<unknown>;
@@ -0,0 +1,60 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
3
+ export function createS3Client(region) {
4
+ return new S3Client({
5
+ maxAttempts: readPositiveIntEnv("COVERAGE_CHECK_S3_MAX_ATTEMPTS", 2),
6
+ region,
7
+ requestHandler: new NodeHttpHandler({
8
+ connectionTimeout: readPositiveIntEnv("COVERAGE_CHECK_S3_CONNECTION_TIMEOUT_MS", 5000),
9
+ requestTimeout: readPositiveIntEnv("COVERAGE_CHECK_S3_REQUEST_TIMEOUT_MS", 30000),
10
+ }),
11
+ });
12
+ }
13
+ export async function sendS3(client, bucket, operation, key, command, details = {}) {
14
+ const start = performance.now();
15
+ try {
16
+ const result = await client.send(command);
17
+ logS3(bucket, operation, key, "ok", performance.now() - start, details);
18
+ return result;
19
+ }
20
+ catch (err) {
21
+ logS3(bucket, operation, key, "failed", performance.now() - start, {
22
+ ...details,
23
+ error: formatError(err),
24
+ });
25
+ throw err;
26
+ }
27
+ }
28
+ function logS3(bucket, operation, key, status, elapsedMs, details) {
29
+ const parts = [
30
+ "coverage-check s3",
31
+ `operation=${JSON.stringify(operation)}`,
32
+ `status=${status}`,
33
+ `bucket=${JSON.stringify(bucket)}`,
34
+ `key=${JSON.stringify(key)}`,
35
+ `elapsed_ms=${Math.round(elapsedMs)}`,
36
+ ];
37
+ if (details.rawBytes !== undefined)
38
+ parts.push(`raw_bytes=${details.rawBytes}`);
39
+ if (details.storedBytes !== undefined)
40
+ parts.push(`stored_bytes=${details.storedBytes}`);
41
+ if (details.error !== undefined)
42
+ parts.push(`error=${JSON.stringify(details.error)}`);
43
+ process.stderr.write(`${parts.join(" ")}\n`);
44
+ }
45
+ function readPositiveIntEnv(name, fallback) {
46
+ const raw = process.env[name];
47
+ if (raw === undefined || raw === "")
48
+ return fallback;
49
+ const value = Number(raw);
50
+ if (!Number.isInteger(value) || value <= 0) {
51
+ process.stderr.write(`coverage-check s3 invalid ${name}=${JSON.stringify(raw)}; using ${fallback}\n`);
52
+ return fallback;
53
+ }
54
+ return value;
55
+ }
56
+ function formatError(err) {
57
+ if (err instanceof Error)
58
+ return `${err.name}: ${err.message}`;
59
+ return String(err);
60
+ }
@@ -0,0 +1,17 @@
1
+ export type StoredPointer = {
2
+ sha: string;
3
+ timestamp?: string;
4
+ payloadKey?: string;
5
+ encoding?: "gzip";
6
+ rawBytes?: number;
7
+ storedBytes?: number;
8
+ };
9
+ type ReadContext = {
10
+ bucket: string;
11
+ key(...parts: string[]): string;
12
+ sendS3(operation: string, key: string, command: object): Promise<unknown>;
13
+ };
14
+ export declare function getLegacy(ctx: ReadContext, suite: string): Promise<Buffer | null>;
15
+ export declare function shouldWritePointer(ctx: ReadContext, suite: string, branch: string, incomingTimestamp: string): Promise<boolean>;
16
+ export declare function readPointer(ctx: ReadContext, suite: string, branch: string): Promise<StoredPointer>;
17
+ export {};
@@ -0,0 +1,57 @@
1
+ import { GetObjectCommand } from "@aws-sdk/client-s3";
2
+ import { encodeBranchName, isNewerTimestamp } from "./suite-store.mjs";
3
+ import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
4
+ export async function getLegacy(ctx, suite) {
5
+ const key = ctx.key(suite, "lcov.info");
6
+ try {
7
+ const resp = (await ctx.sendS3("get legacy coverage payload", key, new GetObjectCommand({
8
+ Bucket: ctx.bucket,
9
+ Key: key,
10
+ })));
11
+ return bodyToBuffer(resp.Body);
12
+ }
13
+ catch (err) {
14
+ if (isNotFound(err))
15
+ return null;
16
+ throw err;
17
+ }
18
+ }
19
+ export async function shouldWritePointer(ctx, suite, branch, incomingTimestamp) {
20
+ const key = ctx.key(suite, "branch", encodeBranchName(branch), "latest.json");
21
+ try {
22
+ const resp = (await ctx.sendS3("get branch pointer for compare", key, new GetObjectCommand({
23
+ Bucket: ctx.bucket,
24
+ Key: key,
25
+ })));
26
+ if (resp.Body === undefined)
27
+ return true;
28
+ const body = await bodyToBuffer(resp.Body);
29
+ const current = JSON.parse(body.toString("utf8"));
30
+ return !isNewerTimestamp(current.timestamp, incomingTimestamp);
31
+ }
32
+ catch (err) {
33
+ if (isNotFound(err))
34
+ return true;
35
+ throw err;
36
+ }
37
+ }
38
+ export async function readPointer(ctx, suite, branch) {
39
+ const keys = [
40
+ ctx.key(suite, "branch", encodeBranchName(branch), "latest.json"),
41
+ ctx.key(suite, "branch", branch, "latest.json"),
42
+ ];
43
+ let lastNotFound;
44
+ for (const key of keys) {
45
+ try {
46
+ const resp = (await ctx.sendS3("get branch pointer", key, new GetObjectCommand({ Bucket: ctx.bucket, Key: key })));
47
+ const body = await bodyToBuffer(resp.Body);
48
+ return JSON.parse(body.toString("utf8"));
49
+ }
50
+ catch (err) {
51
+ if (!isNotFound(err))
52
+ throw err;
53
+ lastNotFound = err;
54
+ }
55
+ }
56
+ throw lastNotFound;
57
+ }
@@ -1,7 +1,5 @@
1
+ import type { ClientLike, S3OperationDetails } from "./s3-diagnostics.mts";
1
2
  import type { SuitePutMeta, SuiteStore } from "./suite-store.mts";
2
- type ClientLike = {
3
- send(cmd: object): Promise<unknown>;
4
- };
5
3
  export type S3SuiteStoreOptions = {
6
4
  bucket: string;
7
5
  prefix?: string;
@@ -10,19 +8,19 @@ export type S3SuiteStoreOptions = {
10
8
  client?: ClientLike;
11
9
  };
12
10
  export declare class S3SuiteStore implements SuiteStore {
13
- private readonly bucket;
11
+ readonly bucket: string;
14
12
  private readonly prefix;
15
13
  private readonly client;
16
14
  constructor({ bucket, prefix, region, client }: S3SuiteStoreOptions);
17
- private key;
15
+ key(...parts: string[]): string;
18
16
  list(): Promise<string[]>;
19
17
  get(suite: string, opts?: {
20
18
  sha?: string;
21
19
  branch?: string;
22
20
  }): Promise<Buffer | null>;
23
21
  put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
24
- private getLegacy;
25
- private shouldWritePointer;
26
- private readPointer;
22
+ sendS3(operation: string, key: string, command: object, details?: S3OperationDetails): Promise<unknown>;
23
+ private getVersionedPayload;
24
+ private putLegacyPayload;
25
+ private putPointer;
27
26
  }
28
- export {};
@@ -1,5 +1,8 @@
1
- import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
2
- import { assertSafePathComponent, assertValidTimestamp, encodeBranchName, isNewerTimestamp, } from "./suite-store.mjs";
1
+ import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
2
+ import { gunzipSync, gzipSync } from "node:zlib";
3
+ import { assertSafePathComponent, assertValidTimestamp, encodeBranchName } from "./suite-store.mjs";
4
+ import { createS3Client, sendS3 } from "./s3-diagnostics.mjs";
5
+ import { getLegacy, readPointer, shouldWritePointer } from "./s3-suite-store-reads.mjs";
3
6
  import { bodyToBuffer, isNotFound } from "./s3-utils.mjs";
4
7
  export class S3SuiteStore {
5
8
  bucket;
@@ -8,7 +11,7 @@ export class S3SuiteStore {
8
11
  constructor({ bucket, prefix, region, client }) {
9
12
  this.bucket = bucket;
10
13
  this.prefix = prefix ? prefix.replace(/\/+$/, "") : "";
11
- this.client = client ?? new S3Client({ region });
14
+ this.client = client ?? createS3Client(region);
12
15
  }
13
16
  key(...parts) {
14
17
  return this.prefix ? [this.prefix, ...parts].join("/") : parts.join("/");
@@ -18,7 +21,7 @@ export class S3SuiteStore {
18
21
  const suites = [];
19
22
  let continuationToken;
20
23
  do {
21
- const resp = (await this.client.send(new ListObjectsV2Command({
24
+ const resp = (await this.sendS3("list suites", this.key(), new ListObjectsV2Command({
22
25
  Bucket: this.bucket,
23
26
  Prefix: pfx,
24
27
  Delimiter: "/",
@@ -36,112 +39,101 @@ export class S3SuiteStore {
36
39
  if (opts?.sha !== undefined)
37
40
  assertSafePathComponent(opts.sha, "sha");
38
41
  let sha = opts?.sha;
42
+ let pointer = null;
39
43
  if (!sha) {
40
44
  const branch = opts?.branch ?? "main";
41
45
  try {
42
- const pointer = await this.readPointer(suite, branch);
46
+ pointer = await readPointer(this, suite, branch);
43
47
  assertSafePathComponent(pointer.sha, "sha");
44
48
  sha = pointer.sha;
45
49
  }
46
50
  catch (err) {
47
51
  if (isNotFound(err))
48
- return this.getLegacy(suite);
52
+ return getLegacy(this, suite);
49
53
  throw err;
50
54
  }
51
55
  }
52
- try {
53
- const resp = (await this.client.send(new GetObjectCommand({
54
- Bucket: this.bucket,
55
- Key: this.key(suite, "sha", sha, "lcov.info"),
56
- })));
57
- return bodyToBuffer(resp.Body);
58
- }
59
- catch (err) {
60
- if (isNotFound(err))
61
- return null;
62
- throw err;
63
- }
56
+ return this.getVersionedPayload(suite, sha, pointer, opts?.sha !== undefined);
64
57
  }
65
58
  async put(suite, lcov, meta) {
66
59
  assertSafePathComponent(suite, "suite");
67
60
  if (meta === undefined) {
68
- await this.client.send(new PutObjectCommand({
69
- Bucket: this.bucket,
70
- Key: this.key(suite, "lcov.info"),
71
- Body: lcov,
72
- ContentType: "text/plain",
73
- }));
61
+ await this.putLegacyPayload(suite, lcov);
74
62
  return;
75
63
  }
76
64
  const { sha, branch } = meta;
77
65
  assertSafePathComponent(sha, "sha");
78
66
  const ts = meta.timestamp ?? new Date().toISOString();
79
67
  assertValidTimestamp(ts);
80
- await this.client.send(new PutObjectCommand({
68
+ const payload = gzipSync(lcov);
69
+ const payloadKey = this.key(suite, "sha", sha, "lcov.info.gz");
70
+ await this.sendS3("put coverage payload", payloadKey, new PutObjectCommand({
81
71
  Bucket: this.bucket,
82
- Key: this.key(suite, "sha", sha, "lcov.info"),
83
- Body: lcov,
72
+ Key: payloadKey,
73
+ Body: payload,
74
+ ContentEncoding: "gzip",
84
75
  ContentType: "text/plain",
85
- }));
86
- if (!(await this.shouldWritePointer(suite, branch, ts)))
76
+ }), { rawBytes: lcov.byteLength, storedBytes: payload.byteLength });
77
+ if (!(await shouldWritePointer(this, suite, branch, ts)))
87
78
  return;
88
- await this.client.send(new PutObjectCommand({
89
- Bucket: this.bucket,
90
- Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
91
- Body: Buffer.from(JSON.stringify({ sha, timestamp: ts }), "utf8"),
92
- ContentType: "application/json",
93
- }));
79
+ await this.putPointer(suite, branch, sha, ts, payloadKey, lcov.byteLength, payload.byteLength);
94
80
  }
95
- async getLegacy(suite) {
96
- try {
97
- const resp = (await this.client.send(new GetObjectCommand({
98
- Bucket: this.bucket,
99
- Key: this.key(suite, "lcov.info"),
100
- })));
101
- return bodyToBuffer(resp.Body);
102
- }
103
- catch (err) {
104
- if (isNotFound(err))
105
- return null;
106
- throw err;
107
- }
81
+ sendS3(operation, key, command, details = {}) {
82
+ return sendS3(this.client, this.bucket, operation, key, command, details);
108
83
  }
109
- async shouldWritePointer(suite, branch, incomingTimestamp) {
110
- try {
111
- const resp = (await this.client.send(new GetObjectCommand({
112
- Bucket: this.bucket,
113
- Key: this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
114
- })));
115
- if (resp.Body === undefined)
116
- return true;
117
- const body = await bodyToBuffer(resp.Body);
118
- const current = JSON.parse(body.toString("utf8"));
119
- return !isNewerTimestamp(current.timestamp, incomingTimestamp);
84
+ async getVersionedPayload(suite, sha, pointer, explicitSha) {
85
+ let candidates;
86
+ if (pointer?.payloadKey !== undefined) {
87
+ candidates = [{ key: pointer.payloadKey, encoding: pointer.encoding }];
120
88
  }
121
- catch (err) {
122
- if (isNotFound(err))
123
- return true;
124
- throw err;
89
+ else if (explicitSha) {
90
+ candidates = [
91
+ { key: this.key(suite, "sha", sha, "lcov.info.gz"), encoding: "gzip" },
92
+ { key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined },
93
+ ];
125
94
  }
126
- }
127
- async readPointer(suite, branch) {
128
- const keys = [
129
- this.key(suite, "branch", encodeBranchName(branch), "latest.json"),
130
- this.key(suite, "branch", branch, "latest.json"),
131
- ];
132
- let lastNotFound;
133
- for (const key of keys) {
95
+ else {
96
+ candidates = [{ key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined }];
97
+ }
98
+ for (const candidate of candidates) {
134
99
  try {
135
- const resp = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })));
100
+ const resp = (await this.sendS3("get coverage payload", candidate.key, new GetObjectCommand({
101
+ Bucket: this.bucket,
102
+ Key: candidate.key,
103
+ })));
136
104
  const body = await bodyToBuffer(resp.Body);
137
- return JSON.parse(body.toString("utf8"));
105
+ return candidate.encoding === "gzip" ? gunzipSync(body) : body;
138
106
  }
139
107
  catch (err) {
140
108
  if (!isNotFound(err))
141
109
  throw err;
142
- lastNotFound = err;
143
110
  }
144
111
  }
145
- throw lastNotFound;
112
+ return null;
113
+ }
114
+ async putLegacyPayload(suite, lcov) {
115
+ const key = this.key(suite, "lcov.info");
116
+ await this.sendS3("put legacy coverage payload", key, new PutObjectCommand({
117
+ Bucket: this.bucket,
118
+ Key: key,
119
+ Body: lcov,
120
+ ContentType: "text/plain",
121
+ }));
122
+ }
123
+ async putPointer(suite, branch, sha, timestamp, payloadKey, rawBytes, storedBytes) {
124
+ const pointerKey = this.key(suite, "branch", encodeBranchName(branch), "latest.json");
125
+ await this.sendS3("put branch pointer", pointerKey, new PutObjectCommand({
126
+ Bucket: this.bucket,
127
+ Key: pointerKey,
128
+ Body: Buffer.from(JSON.stringify({
129
+ sha,
130
+ timestamp,
131
+ payloadKey,
132
+ encoding: "gzip",
133
+ rawBytes,
134
+ storedBytes,
135
+ }), "utf8"),
136
+ ContentType: "application/json",
137
+ }));
146
138
  }
147
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Patch-coverage gate: checks that newly added lines meet per-path coverage thresholds. Supports per-suite LCOV accumulation for conditional CI.",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@aws-sdk/client-s3": "^3.1048.0",
35
+ "@smithy/node-http-handler": "^4.7.7",
35
36
  "js-yaml": "^4.1.1",
36
37
  "monocart-coverage-reports": "^2.12.11"
37
38
  },