@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 +12 -2
- package/bin/check.mjs +3 -88
- package/bin/coverage.mjs +117 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
|
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 })
|
package/bin/coverage.mjs
ADDED
|
@@ -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