@scalequality/check 0.5.2 → 0.5.3

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
@@ -95,7 +95,7 @@ is rejected locally before upload; it is not treated as a full 90-day measuremen
95
95
  - uses: actions/checkout@v4
96
96
  with:
97
97
  fetch-depth: 0
98
- - run: npx @scalequality/check@0.5.2 --project <projectId>
98
+ - run: npx @scalequality/check@0.5.3 --project <projectId>
99
99
  env:
100
100
  SQ_TOKEN: ${{ secrets.SQ_TOKEN }}
101
101
  ```
@@ -126,7 +126,7 @@ that you expect to measure.
126
126
 
127
127
  ```bash
128
128
  # Local verification; does not need a token and sends nothing to ScaleQuality.
129
- npx @scalequality/check@0.5.2 --dry-run
129
+ npx @scalequality/check@0.5.3 --dry-run
130
130
  ```
131
131
 
132
132
  The CLI checks compressed size before allocating the upload buffer or sending a
@@ -191,3 +191,13 @@ A valid report with zero settled or AI-attributed lines still proceeds. It does
191
191
  not invent 100% AI survival. Missing coverage alone does not block submission.
192
192
  A completed check below a configured maturity cutoff still exits with code 1;
193
193
  track-only completion still exits with code 0.
194
+
195
+ ## 0.5.3: complete coverage report collection
196
+
197
+ Coverage discovery and staging now process all matching reports incrementally,
198
+ including deep modules and individual reports larger than 25 MiB. Report bytes
199
+ and paths are preserved; the CLI no longer silently stops after 40 reports.
200
+ A file that changes or cannot be copied stops submission before any upload.
201
+ The existing 60 MiB compressed upload limit, Git durability calculation and
202
+ maturity gates are unchanged. This release requires the matching server reader
203
+ that accepts the complete staged report index.
package/bin/check.mjs CHANGED
@@ -4,12 +4,11 @@
4
4
  // non-zero when maturity is below the configured cutoff, so a CI step can fail the
5
5
  // build. Pure Node 18+ (global fetch/FormData/Blob), no npm dependencies.
6
6
 
7
- import { tmpdir } from "node:os"
8
- import { join, dirname, relative, sep } from "node:path"
9
7
  import { spawnSync } from "node:child_process"
10
- import { readFileSync, writeFileSync, statSync, rmSync, readdirSync, mkdirSync, mkdtempSync, copyFileSync } from "node:fs"
8
+ import { readFileSync } from "node:fs"
11
9
  import { prepareCheckoutArchive, normalizeExcludes } from "./archive.mjs"
12
10
  import { measureDurability } from "./durability.mjs"
11
+ import { stageCoverageReports } from "./coverage.mjs"
13
12
 
14
13
  const args = process.argv.slice(2)
15
14
  const flag = (name) => {
@@ -128,96 +127,12 @@ function slimReport(stdout, ref) {
128
127
  return json
129
128
  }
130
129
 
131
- // Coverage reports live in exactly the directories the archive excludes: JaCoCo
132
- // writes to target/ and build/, Istanbul to coverage/. So the one moment where a
133
- // REAL coverage number is sitting on disk — right after this job ran the tests —
134
- // was also the one moment we threw it away, and every Maven project came back
135
- // with no measured coverage even with a green suite behind it.
136
- //
137
- // The reports are copied into .sqreports/ under FLAT, indexed names, with an
138
- // index.json holding each one's original path. Mirroring the original directory
139
- // tree looked tidier and did not work: bsdtar does not anchor `--exclude=./target`
140
- // to the root, so a staged copy at `.scalequality/coverage/target/...` was dropped
141
- // on macOS while surviving under GNU tar. Measured, not assumed — and a layout
142
- // whose behaviour depends on which tar the runner ships is not a layout.
143
- // Every staged name here is `<n>-<basename>`, which no exclude pattern can match.
144
- const COVERAGE_NAMES = [
145
- { re: /^jacoco(TestReport)?\.xml$/i, format: "jacoco" },
146
- { re: /^lcov\.info$/i, format: "lcov" },
147
- { re: /^coverage-summary\.json$/i, format: "istanbul" },
148
- { re: /^coverage\.cobertura\.xml$/i, format: "cobertura" },
149
- { re: /^cobertura(-coverage)?\.xml$/i, format: "cobertura" },
150
- { re: /^coverage\.xml$/i, format: "cobertura" },
151
- { re: /^clover\.xml$/i, format: "clover" },
152
- { re: /^(coverage|cover)\.out$/i, format: "go" },
153
- ]
154
- const COVERAGE_SKIP = new Set(["node_modules", ".git", ".venv", "venv", "vendor", "__pycache__", ".sqreports"])
155
- const COVERAGE_STAGE = ".sqreports"
156
- const MAX_REPORT_BYTES = 25 * 1024 * 1024
157
- const MAX_REPORTS = 40
158
-
159
- function stageCoverageReports() {
160
- const root = process.cwd()
161
- const stagingRoot = mkdtempSync(join(tmpdir(), "sq-coverage-"))
162
- const stage = join(stagingRoot, COVERAGE_STAGE)
163
- // The staged copies must not outlive this process under ANY exit path. A
164
- // cancelled job or a network failure that left them behind would show up as
165
- // untracked files in the customer's checkout, and a pipeline that runs
166
- // `git diff --exit-code` after the gate would fail because of us.
167
- const clean = () => { try { rmSync(stagingRoot, { recursive: true, force: true }) } catch { /* best effort */ } }
168
- process.on("exit", clean)
169
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
170
- process.on(sig, () => { clean(); process.exit(130) })
171
- }
172
-
173
- const index = []
174
- let bytes = 0
175
- const walk = (dir, depth) => {
176
- if (depth > 7 || index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
177
- let entries
178
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
179
- for (const e of entries) {
180
- if (index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
181
- const abs = join(dir, e.name)
182
- if (e.isDirectory()) {
183
- if (!COVERAGE_SKIP.has(e.name)) walk(abs, depth + 1)
184
- continue
185
- }
186
- if (!e.isFile()) continue
187
- const hit = COVERAGE_NAMES.find((c) => c.re.test(e.name))
188
- if (!hit) continue
189
- let size = 0
190
- try { size = statSync(abs).size } catch { continue }
191
- if (size === 0 || bytes + size > MAX_REPORT_BYTES) continue
192
- const staged = `${index.length}-${e.name}`
193
- try {
194
- mkdirSync(stage, { recursive: true })
195
- copyFileSync(abs, join(stage, staged))
196
- } catch { continue }
197
- bytes += size
198
- index.push({ file: staged, path: relative(root, abs).split(sep).join("/"), format: hit.format })
199
- }
200
- }
201
- walk(root, 0)
202
-
203
- if (index.length) {
204
- try {
205
- writeFileSync(join(stage, "index.json"), JSON.stringify({ version: 1, reports: index }))
206
- } catch { /* the archive just goes without them */ }
207
- const shown = index.slice(0, 3).map((r) => r.path).join(", ")
208
- log(`ScaleQuality: found ${index.length} coverage report(s) — ${shown}${index.length > 3 ? " …" : ""}`)
209
- } else {
210
- log("ScaleQuality: no coverage report found in this job. Run your tests with coverage before this step to get a measured %.")
211
- }
212
- return { stage, root: stagingRoot, count: index.length, cleanup: clean }
213
- }
214
-
215
130
  async function main() {
216
131
  const auth = { Authorization: `Bearer ${token}` }
217
132
  const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
218
133
  const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
219
134
  if (!durabilityReport) fail("Git durability collection failed. No upload was sent and no new score was submitted. Resolve the Git error above before rerunning this step.")
220
- const coverage = stageCoverageReports()
135
+ const coverage = await stageCoverageReports({ log })
221
136
  let packed, buf
222
137
  try {
223
138
  packed = prepareCheckoutArchive({ root: process.cwd(), coverageRoot: coverage.root, durabilityReport, excludes, log })
@@ -0,0 +1,117 @@
1
+ import { constants, rmSync } from "node:fs"
2
+ import { open, opendir, mkdir, mkdtemp } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join, relative, sep } from "node:path"
5
+
6
+ const NAMES = [
7
+ { re: /^jacoco(TestReport)?\.xml$/i, format: "jacoco" },
8
+ { re: /^lcov\.info$/i, format: "lcov" },
9
+ { re: /^coverage-summary\.json$/i, format: "istanbul" },
10
+ { re: /^coverage\.cobertura\.xml$/i, format: "cobertura" },
11
+ { re: /^cobertura(-coverage)?\.xml$/i, format: "cobertura" },
12
+ { re: /^coverage\.xml$/i, format: "cobertura" },
13
+ { re: /^clover\.xml$/i, format: "clover" },
14
+ { re: /^(coverage|cover)\.out$/i, format: "go" },
15
+ ]
16
+ const SKIP = new Set(["node_modules", ".git", ".venv", "venv", "vendor", "__pycache__", ".sqreports"])
17
+ const STAGE = ".sqreports"
18
+
19
+ // Copy sequentially using a fixed buffer. Report count and uncompressed bytes
20
+ // do not determine which sources survive. The final compressed checkout still
21
+ // has the existing upload limit, checked by archive.mjs before any HTTP request.
22
+ async function copyReport(source, destination) {
23
+ let input, output
24
+ try {
25
+ input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
26
+ const before = await input.stat({ bigint: true })
27
+ if (!before.isFile() || before.size === 0n) throw new Error("report is empty or is not a regular file")
28
+ output = await open(destination, "wx")
29
+ const buffer = Buffer.allocUnsafe(64 * 1024)
30
+ let copied = 0n
31
+ while (true) {
32
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, null)
33
+ if (!bytesRead) break
34
+ let written = 0
35
+ while (written < bytesRead) {
36
+ const { bytesWritten } = await output.write(buffer, written, bytesRead - written, null)
37
+ if (!bytesWritten) throw new Error("report copy made no progress")
38
+ written += bytesWritten
39
+ }
40
+ copied += BigInt(bytesRead)
41
+ if (copied > before.size) throw new Error("report changed during collection")
42
+ }
43
+ const after = await input.stat({ bigint: true })
44
+ if (copied !== before.size || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
45
+ throw new Error("report changed during collection")
46
+ }
47
+ } finally {
48
+ await Promise.all([output?.close(), input?.close()])
49
+ }
50
+ }
51
+
52
+ /** Preserve every discovered original report in a flat v1 index. Flat names
53
+ * keep GNU/BSD tar exclusions from dropping nested build/target reports.
54
+ * Generated
55
+ * directories are scanned too: they are exactly where test runners write.
56
+ * No customer files are created or changed. A failed scan/copy/index never
57
+ * returns a partial set that could be presented as a complete measurement. */
58
+ export async function stageCoverageReports({ root = process.cwd(), log = () => {} } = {}) {
59
+ const stagingRoot = await mkdtemp(join(tmpdir(), "sq-coverage-"))
60
+ const stage = join(stagingRoot, STAGE)
61
+ const remove = () => { try { rmSync(stagingRoot, { recursive: true, force: true }) } catch { /* best effort on process exit */ } }
62
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"]
63
+ const onSignal = () => { remove(); process.exit(130) }
64
+ process.once("exit", remove)
65
+ for (const signal of signals) process.once(signal, onSignal)
66
+ const cleanup = () => {
67
+ process.removeListener("exit", remove)
68
+ for (const signal of signals) process.removeListener(signal, onSignal)
69
+ remove()
70
+ }
71
+ let index
72
+ let count = 0
73
+ const shown = []
74
+ let current = "."
75
+ const walk = async (directory) => {
76
+ current = relative(root, directory).split(sep).join("/") || "."
77
+ const entries = await opendir(directory)
78
+ for await (const entry of entries) {
79
+ const absolute = join(directory, entry.name)
80
+ if (entry.isDirectory()) {
81
+ if (!SKIP.has(entry.name)) await walk(absolute)
82
+ continue
83
+ }
84
+ if (!entry.isFile()) continue // Never follow report or directory symlinks.
85
+ const match = NAMES.find(candidate => candidate.re.test(entry.name))
86
+ if (!match) continue
87
+ current = relative(root, absolute).split(sep).join("/")
88
+ if (!index) {
89
+ await mkdir(stage)
90
+ index = await open(join(stage, "index.json"), "wx")
91
+ await index.writeFile('{"version":1,"reports":[')
92
+ }
93
+ const file = `${count}-${entry.name}`
94
+ await copyReport(absolute, join(stage, file))
95
+ await index.writeFile((count ? "," : "") + JSON.stringify({ file, path: current, format: match.format }))
96
+ count++
97
+ if (shown.length < 3) shown.push(current)
98
+ }
99
+ }
100
+ try {
101
+ await walk(root)
102
+ if (index) {
103
+ await index.writeFile("]}\n")
104
+ await index.close()
105
+ index = undefined
106
+ log(`ScaleQuality: found ${count} coverage report(s) — ${shown.join(", ")}${count > 3 ? " …" : ""}`)
107
+ } else {
108
+ log("ScaleQuality: no coverage report found in this job. Run your tests with coverage before this step to get a measured %.")
109
+ }
110
+ return { stage, root: stagingRoot, count, cleanup }
111
+ } catch (error) {
112
+ await index?.close().catch(() => {})
113
+ cleanup()
114
+ const reason = typeof error?.code === "string" ? error.code : error?.message || "I/O error"
115
+ throw new Error(`Coverage collection could not finish at ${JSON.stringify(current)} (${reason}). No upload was sent and no partial coverage was submitted.`)
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line.",
5
5
  "type": "module",
6
6
  "bin": {