coverage-check 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, encodedBranch, incomingTimestamp) {
20
+ const key = ctx.key(suite, "branch", encodedBranch, "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,28 +1,25 @@
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;
8
6
  region?: string;
9
- /** Inject a custom S3 client (e.g. for testing). */
10
7
  client?: ClientLike;
11
8
  };
12
9
  export declare class S3SuiteStore implements SuiteStore {
13
- private readonly bucket;
10
+ readonly bucket: string;
14
11
  private readonly prefix;
15
12
  private readonly client;
16
13
  constructor({ bucket, prefix, region, client }: S3SuiteStoreOptions);
17
- private key;
14
+ key(...parts: string[]): string;
18
15
  list(): Promise<string[]>;
19
16
  get(suite: string, opts?: {
20
17
  sha?: string;
21
18
  branch?: string;
22
19
  }): Promise<Buffer | null>;
23
20
  put(suite: string, lcov: Buffer, meta?: SuitePutMeta): Promise<void>;
24
- private getLegacy;
25
- private shouldWritePointer;
26
- private readPointer;
21
+ sendS3(operation: string, key: string, command: object, details?: S3OperationDetails): Promise<unknown>;
22
+ private getVersionedPayload;
23
+ private putLegacyPayload;
24
+ private putPointer;
27
25
  }
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,102 @@ 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");
66
+ const encodedBranch = encodeBranchName(branch);
78
67
  const ts = meta.timestamp ?? new Date().toISOString();
79
68
  assertValidTimestamp(ts);
80
- await this.client.send(new PutObjectCommand({
69
+ const payload = gzipSync(lcov);
70
+ const payloadKey = this.key(suite, "sha", sha, "lcov.info.gz");
71
+ await this.sendS3("put coverage payload", payloadKey, new PutObjectCommand({
81
72
  Bucket: this.bucket,
82
- Key: this.key(suite, "sha", sha, "lcov.info"),
83
- Body: lcov,
73
+ Key: payloadKey,
74
+ Body: payload,
75
+ ContentEncoding: "gzip",
84
76
  ContentType: "text/plain",
85
- }));
86
- if (!(await this.shouldWritePointer(suite, branch, ts)))
77
+ }), { rawBytes: lcov.byteLength, storedBytes: payload.byteLength });
78
+ if (!(await shouldWritePointer(this, suite, encodedBranch, ts)))
87
79
  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
- }));
80
+ await this.putPointer(suite, branch, sha, ts, payloadKey, lcov.byteLength, payload.byteLength);
94
81
  }
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
- }
82
+ sendS3(operation, key, command, details = {}) {
83
+ return sendS3(this.client, this.bucket, operation, key, command, details);
108
84
  }
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);
85
+ async getVersionedPayload(suite, sha, pointer, explicitSha) {
86
+ let candidates;
87
+ if (pointer?.payloadKey !== undefined) {
88
+ candidates = [{ key: pointer.payloadKey, encoding: pointer.encoding }];
120
89
  }
121
- catch (err) {
122
- if (isNotFound(err))
123
- return true;
124
- throw err;
90
+ else if (explicitSha) {
91
+ candidates = [
92
+ { key: this.key(suite, "sha", sha, "lcov.info.gz"), encoding: "gzip" },
93
+ { key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined },
94
+ ];
125
95
  }
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) {
96
+ else {
97
+ candidates = [{ key: this.key(suite, "sha", sha, "lcov.info"), encoding: undefined }];
98
+ }
99
+ for (const candidate of candidates) {
134
100
  try {
135
- const resp = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })));
101
+ const resp = (await this.sendS3("get coverage payload", candidate.key, new GetObjectCommand({
102
+ Bucket: this.bucket,
103
+ Key: candidate.key,
104
+ })));
136
105
  const body = await bodyToBuffer(resp.Body);
137
- return JSON.parse(body.toString("utf8"));
106
+ return candidate.encoding === "gzip" ? gunzipSync(body) : body;
138
107
  }
139
108
  catch (err) {
140
109
  if (!isNotFound(err))
141
110
  throw err;
142
- lastNotFound = err;
143
111
  }
144
112
  }
145
- throw lastNotFound;
113
+ return null;
114
+ }
115
+ async putLegacyPayload(suite, lcov) {
116
+ const key = this.key(suite, "lcov.info");
117
+ await this.sendS3("put legacy coverage payload", key, new PutObjectCommand({
118
+ Bucket: this.bucket,
119
+ Key: key,
120
+ Body: lcov,
121
+ ContentType: "text/plain",
122
+ }));
123
+ }
124
+ async putPointer(suite, branch, sha, timestamp, payloadKey, rawBytes, storedBytes) {
125
+ const pointerKey = this.key(suite, "branch", encodeBranchName(branch), "latest.json");
126
+ await this.sendS3("put branch pointer", pointerKey, new PutObjectCommand({
127
+ Bucket: this.bucket,
128
+ Key: pointerKey,
129
+ Body: Buffer.from(JSON.stringify({
130
+ sha,
131
+ timestamp,
132
+ payloadKey,
133
+ encoding: "gzip",
134
+ rawBytes,
135
+ storedBytes,
136
+ }), "utf8"),
137
+ ContentType: "application/json",
138
+ }));
146
139
  }
147
140
  }
@@ -1,5 +1,6 @@
1
1
  import type { SuiteMeta } from "./types.mts";
2
2
  export declare function assertSafePathComponent(value: string, label: string): void;
3
+ export declare function assertValidRepo(repo: string): string;
3
4
  export type { SuiteMeta };
4
5
  export type SuitePutMeta = {
5
6
  sha: string;
@@ -10,8 +10,27 @@ export function assertSafePathComponent(value, label) {
10
10
  throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
11
11
  }
12
12
  }
13
+ export function assertValidRepo(repo) {
14
+ const trimmed = typeof repo === "string" ? repo.trim() : "";
15
+ if (trimmed.length === 0) {
16
+ throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
17
+ }
18
+ const parts = trimmed.split("/");
19
+ if (parts.length !== 2 ||
20
+ !/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(parts[0]) ||
21
+ !/^[A-Za-z0-9_.-]+$/.test(parts[1]) ||
22
+ parts[1] === "." ||
23
+ parts[1] === "..") {
24
+ throw new Error(`Invalid repository format: ${repo}. Expected owner/repo.`);
25
+ }
26
+ return trimmed;
27
+ }
13
28
  export function encodeBranchName(branch) {
14
- if (typeof branch !== "string" || branch.length === 0) {
29
+ if (typeof branch !== "string" ||
30
+ branch.length === 0 ||
31
+ branch === "." ||
32
+ branch.includes("..") ||
33
+ branch.includes("\\")) {
15
34
  throw new Error(`invalid branch: ${JSON.stringify(branch)}`);
16
35
  }
17
36
  return Buffer.from(branch, "utf8").toString("base64url");
@@ -91,10 +110,11 @@ export class FileSystemSuiteStore {
91
110
  }
92
111
  const { sha, branch } = meta;
93
112
  assertSafePathComponent(sha, "sha");
113
+ const encodedBranch = encodeBranchName(branch);
94
114
  const shaDir = join(this.root, suite, "sha", sha);
95
115
  mkdirSync(shaDir, { recursive: true });
96
116
  writeFileSync(join(shaDir, "lcov.info"), lcov);
97
- const branchDir = join(this.root, suite, "branch", encodeBranchName(branch));
117
+ const branchDir = join(this.root, suite, "branch", encodedBranch);
98
118
  mkdirSync(branchDir, { recursive: true });
99
119
  const pointerPath = join(branchDir, "latest.json");
100
120
  const timestamp = meta.timestamp ?? new Date().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coverage-check",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",
@@ -31,7 +31,8 @@
31
31
  "node": ">=22.0.0"
32
32
  },
33
33
  "dependencies": {
34
- "@aws-sdk/client-s3": "^3.1048.0",
34
+ "@aws-sdk/client-s3": "3.1075.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
  },
@@ -40,7 +41,7 @@
40
41
  "@types/node": "^25.8.0",
41
42
  "@vitest/coverage-v8": "^4.1.6",
42
43
  "husky": "^9.1.7",
43
- "oxfmt": "^0.52.0",
44
+ "oxfmt": "^0.53.0",
44
45
  "oxlint": "^1.65.0",
45
46
  "typescript": "^6.0.3",
46
47
  "vitest": "^4.1.6"