@scalequality/check 0.3.1 → 0.5.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.
- package/README.md +87 -0
- package/bin/archive.mjs +109 -0
- package/bin/check.mjs +121 -27
- package/bin/durability/GitDurabilityEngine.mjs +225 -0
- package/bin/durability/GitReader.mjs +118 -0
- package/bin/durability/OriginClassifier.mjs +48 -0
- package/bin/durability/PROVENANCE.md +36 -0
- package/bin/durability/provenance.json +52 -0
- package/bin/durability/runtime.mjs +55 -0
- package/bin/durability/types.mjs +5 -0
- package/bin/durability/validate.mjs +31 -0
- package/bin/durability/worker.mjs +44 -0
- package/bin/durability.mjs +65 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ and **Turn on continuous measurement**. Store the token as a CI secret named
|
|
|
21
21
|
| `--host` | `SQ_HOST` | `https://app.scalequality.io` | API host |
|
|
22
22
|
| `--timeout` | — | `600` | Max seconds to wait for the measurement |
|
|
23
23
|
| `--json` | — | off | Print the raw result as JSON |
|
|
24
|
+
| `--exclude <path>` | — | — | Exclude a generated file/directory by literal checkout-relative path; repeatable |
|
|
25
|
+
| `--dry-run` | — | off | Measure locally and validate the package without uploading; no token needed |
|
|
24
26
|
|
|
25
27
|
Exit codes: `0` passed (or track-only), `1` below the cutoff, `2` error.
|
|
26
28
|
|
|
@@ -45,3 +47,88 @@ scalequality:
|
|
|
45
47
|
|
|
46
48
|
Any other CI: run the same `npx @scalequality/check` command, with `SQ_TOKEN`
|
|
47
49
|
exposed as a secret/variable.
|
|
50
|
+
|
|
51
|
+
## Measured coverage
|
|
52
|
+
|
|
53
|
+
Coverage is only real once the tests have run. If this job produces a coverage
|
|
54
|
+
report, the check finds it and ScaleQuality shows **measured line coverage**
|
|
55
|
+
instead of a directional estimate. Nothing to configure: run your tests with
|
|
56
|
+
coverage before this step.
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
- run: mvn verify # writes target/site/jacoco/jacoco.xml
|
|
60
|
+
- run: npx @scalequality/check --token "$SQ_TOKEN" --project <projectId>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Recognised out of the box: JaCoCo (`jacoco.xml`), LCOV (`lcov.info`), Cobertura
|
|
64
|
+
(`coverage.xml`, `coverage.cobertura.xml`), Istanbul (`coverage-summary.json`),
|
|
65
|
+
Clover (`clover.xml`) and Go (`coverage.out`). Multi-module builds are summed.
|
|
66
|
+
|
|
67
|
+
The reports are copied outside your checkout into temporary staging, then stored
|
|
68
|
+
as `.sqreports/` entries in the upload. Build-output exclusions do not discard
|
|
69
|
+
these measured reports, and your working tree is not modified.
|
|
70
|
+
|
|
71
|
+
Report somewhere unusual? Point at it in `scalequality.properties` at the repo
|
|
72
|
+
root:
|
|
73
|
+
|
|
74
|
+
```properties
|
|
75
|
+
sq.coverage.reportPaths=ops/reports/jacoco-aggregate.xml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Full guide: https://scalequality.io/docs/continuous-measurement
|
|
79
|
+
|
|
80
|
+
## Fixed history window and large repositories (0.5.0)
|
|
81
|
+
|
|
82
|
+
Durability is calculated **on the CI runner**, against its existing Git history,
|
|
83
|
+
using the same native engine as ScaleQuality: a fixed **90-day** window, with
|
|
84
|
+
**14 days** for introduced code to settle before survival is measured. The source
|
|
85
|
+
engine revision and report time travel with the result. The CLI sends current
|
|
86
|
+
source, coverage, and a bounded durability report (at most 1 MiB); it never uploads
|
|
87
|
+
the raw `.git` object database. Years of old binary history therefore do not add
|
|
88
|
+
to the archive size. It does not truncate or change your repository.
|
|
89
|
+
|
|
90
|
+
Use a complete checkout for that measurement. Shallow history is reported as
|
|
91
|
+
unavailable; it is not treated as a full 90-day measurement.
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
# GitHub Actions
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
with:
|
|
97
|
+
fetch-depth: 0
|
|
98
|
+
- run: npx @scalequality/check@0.5.0 --project <projectId>
|
|
99
|
+
env:
|
|
100
|
+
SQ_TOKEN: ${{ secrets.SQ_TOKEN }}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For GitLab set `GIT_DEPTH: "0"`. For Bitbucket Pipelines set:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
clone:
|
|
107
|
+
depth: full
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The current-code scanners still run on the server. Historical secret scanning and
|
|
111
|
+
commit-convention analysis are not included in this report; the result shows those
|
|
112
|
+
limits and identifies durability as reported by CI. CPU, time, malformed Git or
|
|
113
|
+
report-size limits yield unavailable history, never a fabricated clean result.
|
|
114
|
+
|
|
115
|
+
Version 0.5.0 needs the compatible ScaleQuality server release. The CLI checks the
|
|
116
|
+
server's acknowledgement of this exact report before declaring a passing gate.
|
|
117
|
+
|
|
118
|
+
The compressed **source-and-reports archive** remains limited to 60 MiB. A time
|
|
119
|
+
window cannot guarantee that arbitrary source assets fit: one current binary can
|
|
120
|
+
exceed the limit. Generated dependency/output directories (`node_modules`,
|
|
121
|
+
`dist`, `build`, `.next`, `out`, `target`, `.venv`, `venv`, `coverage`,
|
|
122
|
+
`.turbo`) are excluded at every depth, while lockfiles are retained.
|
|
123
|
+
Use `--exclude generated/assets` for other generated content. Paths are literal,
|
|
124
|
+
relative to the checkout, and do not use shell wildcards. Do not exclude source
|
|
125
|
+
that you expect to measure.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# Local verification; does not need a token and sends nothing to ScaleQuality.
|
|
129
|
+
npx @scalequality/check@0.5.0 --dry-run
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The CLI checks compressed size before allocating the upload buffer or sending a
|
|
133
|
+
request. If current source alone cannot fit, it lists the largest included files
|
|
134
|
+
and exits with an actionable error. No oversized upload is sent.
|
package/bin/archive.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { mkdtempSync, mkdirSync, readdirSync, lstatSync, existsSync, statSync, writeFileSync, rmSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join, resolve, posix } from "node:path"
|
|
5
|
+
|
|
6
|
+
export const ARCHIVE_LIMIT_BYTES = 60 * 1024 * 1024
|
|
7
|
+
const MAX_DURABILITY_REPORT_BYTES = 1024 * 1024
|
|
8
|
+
// Directory names, at every depth. Explicit file manifests avoid GNU/BSD tar
|
|
9
|
+
// differences in matching nested output/dependency directories.
|
|
10
|
+
const SKIP = new Set(["node_modules", "dist", "build", ".next", "out", "target", ".venv", "venv", "coverage", ".turbo"])
|
|
11
|
+
const mib = (bytes) => (bytes / 1024 / 1024).toFixed(1)
|
|
12
|
+
|
|
13
|
+
export function normalizeExcludes(excludes = []) {
|
|
14
|
+
return excludes.map((value) => {
|
|
15
|
+
if (typeof value !== "string" || !value || value.startsWith("-") || value.includes("\0") || value.includes("\\") || /[*?\[\]]/.test(value)) {
|
|
16
|
+
throw new Error("--exclude needs a literal path relative to the checkout (no wildcards).")
|
|
17
|
+
}
|
|
18
|
+
const path = posix.normalize(value.replace(/^\.\//, "")).replace(/\/$/, "")
|
|
19
|
+
if (!path || path === "." || path.startsWith("/") || path === ".." || path.startsWith("../") || /^[A-Za-z]:/.test(path) || [".git", ".sqreports"].includes(path.split("/")[0])) {
|
|
20
|
+
throw new Error("--exclude must stay inside the checkout and cannot exclude .git or .sqreports.")
|
|
21
|
+
}
|
|
22
|
+
return path
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inventory(root, { reports = false, excludes = [] } = {}) {
|
|
27
|
+
const paths = [], largest = []
|
|
28
|
+
const walk = (rel) => {
|
|
29
|
+
const abs = join(root, rel)
|
|
30
|
+
const st = lstatSync(abs)
|
|
31
|
+
paths.push(`./${rel}`)
|
|
32
|
+
if (st.isDirectory()) {
|
|
33
|
+
for (const entry of readdirSync(abs).sort()) visit(rel ? `${rel}/${entry}` : entry, entry)
|
|
34
|
+
} else if (st.isFile()) {
|
|
35
|
+
largest.push({ path: rel, bytes: st.size })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const visit = (rel, name) => {
|
|
39
|
+
if (!reports && (name === ".git" || rel === ".sqreports" || excludes.some((p) => rel === p || rel.startsWith(`${p}/`)))) return
|
|
40
|
+
if (!reports && SKIP.has(name) && lstatSync(join(root, rel)).isDirectory()) return
|
|
41
|
+
walk(rel)
|
|
42
|
+
}
|
|
43
|
+
for (const entry of readdirSync(root).sort()) visit(entry, entry)
|
|
44
|
+
return { paths, largest: largest.sort((a, b) => b.bytes - a.bytes).slice(0, 5) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Upload current source plus bounded measurement reports, never raw Git objects.
|
|
49
|
+
* The 90-day durability calculation happens against the original local history;
|
|
50
|
+
* no shallow clone, artificial root commit or changes to the customer's checkout.
|
|
51
|
+
*/
|
|
52
|
+
export function prepareCheckoutArchive({ root, coverageRoot, durabilityReport, log = () => {}, maxBytes = ARCHIVE_LIMIT_BYTES, excludes = [] }) {
|
|
53
|
+
root = resolve(root)
|
|
54
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > ARCHIVE_LIMIT_BYTES) throw new Error("Invalid archive size limit.")
|
|
55
|
+
excludes = normalizeExcludes(excludes)
|
|
56
|
+
const temp = mkdtempSync(join(tmpdir(), "sq-checkout-"))
|
|
57
|
+
const onExit = () => { try { rmSync(temp, { recursive: true, force: true }) } catch { /* best effort on process exit */ } }
|
|
58
|
+
process.once("exit", onExit)
|
|
59
|
+
const cleanup = () => { process.removeListener("exit", onExit); rmSync(temp, { recursive: true, force: true }) }
|
|
60
|
+
try {
|
|
61
|
+
const source = inventory(root, { excludes })
|
|
62
|
+
const sourceList = join(temp, "source.list")
|
|
63
|
+
writeFileSync(sourceList, source.paths.length ? `${source.paths.join("\0")}\0` : "")
|
|
64
|
+
const metadataRoot = join(temp, "metadata")
|
|
65
|
+
mkdirSync(join(metadataRoot, ".sqreports"), { recursive: true })
|
|
66
|
+
const hasGit = existsSync(join(root, ".git"))
|
|
67
|
+
const history = { mode: hasGit ? "omitted" : "unavailable", reason: durabilityReport ? "ci_report" : hasGit ? "invalid_git" : "missing_git" }
|
|
68
|
+
writeFileSync(join(metadataRoot, ".sqreports/checkout.json"), JSON.stringify({ version: 1, history, archiveLimitBytes: ARCHIVE_LIMIT_BYTES }))
|
|
69
|
+
if (durabilityReport) {
|
|
70
|
+
const json = JSON.stringify(durabilityReport)
|
|
71
|
+
if (Buffer.byteLength(json) > MAX_DURABILITY_REPORT_BYTES) throw new Error("Durability report exceeds the 1 MiB report limit. No upload was sent.")
|
|
72
|
+
writeFileSync(join(metadataRoot, ".sqreports/durability.json"), json)
|
|
73
|
+
}
|
|
74
|
+
const metadataList = join(temp, "metadata.list")
|
|
75
|
+
writeFileSync(metadataList, `${inventory(metadataRoot, { reports: true }).paths.join("\0")}\0`)
|
|
76
|
+
const tarPath = join(temp, "checkout.tar")
|
|
77
|
+
const archivePath = `${tarPath}.gz`
|
|
78
|
+
// BSD tar uses only the last -T list. Build/append one directory and list
|
|
79
|
+
// per invocation, then stream compression through Node (no extra dependency).
|
|
80
|
+
const archivePart = (operation, directory, list) => {
|
|
81
|
+
const tar = spawnSync("tar", [operation, tarPath, "--no-recursion", "--null", "-C", directory, "-T", list], { encoding: "utf8", timeout: 180000, maxBuffer: 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: "1" } })
|
|
82
|
+
if (tar.error || tar.status !== 0) throw new Error("Could not archive the checkout. Ensure GNU/BSD tar is installed, files are readable, and the build has finished changing files.")
|
|
83
|
+
}
|
|
84
|
+
archivePart("-cf", root, sourceList)
|
|
85
|
+
if (coverageRoot) {
|
|
86
|
+
const coverageList = join(temp, "coverage.list")
|
|
87
|
+
const entries = inventory(coverageRoot, { reports: true }).paths
|
|
88
|
+
writeFileSync(coverageList, entries.length ? `${entries.join("\0")}\0` : "")
|
|
89
|
+
if (entries.length) archivePart("-rf", coverageRoot, coverageList)
|
|
90
|
+
}
|
|
91
|
+
archivePart("-rf", metadataRoot, metadataList)
|
|
92
|
+
const compression = spawnSync(process.execPath, ["--input-type=module", "-e", "import {createReadStream,createWriteStream} from 'node:fs';import{createGzip}from'node:zlib';import{pipeline}from'node:stream/promises';await pipeline(createReadStream(process.argv[1]),createGzip(),createWriteStream(process.argv[2]));", tarPath, archivePath], { encoding: "utf8", timeout: 180000, maxBuffer: 1024 * 1024 })
|
|
93
|
+
// A nonempty file can still be truncated. Never upload an archive produced
|
|
94
|
+
// by failed tar or a checkout that changed while it was being archived.
|
|
95
|
+
if (compression.error || compression.status !== 0 || !existsSync(archivePath) || statSync(archivePath).size === 0) {
|
|
96
|
+
throw new Error("Could not archive the checkout. Ensure GNU/BSD tar is installed, files are readable, and the build has finished changing files.")
|
|
97
|
+
}
|
|
98
|
+
const bytes = statSync(archivePath).size
|
|
99
|
+
if (bytes > maxBytes) {
|
|
100
|
+
const files = source.largest.map((f) => `${JSON.stringify(f.path)} (${mib(f.bytes)} MiB uncompressed)`).join(", ")
|
|
101
|
+
throw new Error(`source archive alone is ${mib(bytes)} MiB, above the ${mib(maxBytes)} MiB limit even without Git history. No upload was sent. Exclude generated assets with --exclude <relative-path>. Largest included files: ${files || "none"}`)
|
|
102
|
+
}
|
|
103
|
+
log(`ScaleQuality: checkout archive ${mib(bytes)} MiB / ${mib(maxBytes)} MiB; raw Git history stays on this runner.`)
|
|
104
|
+
return { archivePath, bytes, history, cleanup }
|
|
105
|
+
} catch (error) {
|
|
106
|
+
cleanup()
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
}
|
package/bin/check.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ScaleQuality continuous check. Uploads THIS CI checkout to ScaleQuality (re-measures
|
|
3
|
-
// the code the runner already has,
|
|
3
|
+
// the code the runner already has, with a local 90-day durability report) and exits
|
|
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
7
|
import { tmpdir } from "node:os"
|
|
8
|
-
import { join } from "node:path"
|
|
8
|
+
import { join, dirname, relative, sep } from "node:path"
|
|
9
9
|
import { spawnSync } from "node:child_process"
|
|
10
|
-
import { readFileSync,
|
|
10
|
+
import { readFileSync, writeFileSync, statSync, rmSync, readdirSync, mkdirSync, mkdtempSync, copyFileSync } from "node:fs"
|
|
11
|
+
import { prepareCheckoutArchive, normalizeExcludes } from "./archive.mjs"
|
|
12
|
+
import { measureDurability } from "./durability.mjs"
|
|
11
13
|
|
|
12
14
|
const args = process.argv.slice(2)
|
|
13
15
|
const flag = (name) => {
|
|
@@ -30,6 +32,8 @@ Options:
|
|
|
30
32
|
myapp:\${{ github.sha }}. Needs trivy on PATH; skipped with a
|
|
31
33
|
note if it is missing, never fails the build for it.
|
|
32
34
|
--timeout <s> Max seconds to wait for the measurement (default 600)
|
|
35
|
+
--exclude <p> Exclude a generated path relative to the checkout; repeatable
|
|
36
|
+
--dry-run Measure locally and check the package; do not upload (no token needed)
|
|
33
37
|
--json Print the raw result as JSON
|
|
34
38
|
-h, --help Show this help
|
|
35
39
|
|
|
@@ -41,6 +45,7 @@ const token = flag("--token") || process.env.SQ_TOKEN
|
|
|
41
45
|
const project = flag("--project") || process.env.SQ_PROJECT
|
|
42
46
|
const host = (flag("--host") || process.env.SQ_HOST || "https://app.scalequality.io").replace(/\/+$/, "")
|
|
43
47
|
const jsonOut = has("--json")
|
|
48
|
+
const dryRun = has("--dry-run")
|
|
44
49
|
const image = flag("--image") || process.env.SQ_IMAGE
|
|
45
50
|
const timeoutMs = Math.max(30, Number(flag("--timeout") || 600)) * 1000
|
|
46
51
|
|
|
@@ -49,7 +54,7 @@ const log = (m) => { if (!jsonOut) console.log(m) }
|
|
|
49
54
|
const fail = (m) => { console.error(`ScaleQuality: ${m}`); process.exit(2) }
|
|
50
55
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
51
56
|
|
|
52
|
-
if (!token || !project) {
|
|
57
|
+
if (!dryRun && (!token || !project)) {
|
|
53
58
|
fail("--token and --project are required (or env SQ_TOKEN / SQ_PROJECT). See --help.")
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -123,34 +128,119 @@ function slimReport(stdout, ref) {
|
|
|
123
128
|
return json
|
|
124
129
|
}
|
|
125
130
|
|
|
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
|
+
|
|
126
215
|
async function main() {
|
|
127
216
|
const auth = { Authorization: `Bearer ${token}` }
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
217
|
+
const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
|
|
218
|
+
const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
|
|
219
|
+
const coverage = stageCoverageReports()
|
|
220
|
+
let packed, buf
|
|
221
|
+
try {
|
|
222
|
+
packed = prepareCheckoutArchive({ root: process.cwd(), coverageRoot: coverage.root, durabilityReport, excludes, log })
|
|
223
|
+
if (durabilityReport) {
|
|
224
|
+
const head = spawnSync("git", ["--no-optional-locks", "-C", process.cwd(), "rev-parse", "--verify", "HEAD"], { encoding: "utf8", timeout: 10000 })
|
|
225
|
+
if (head.error || head.status !== 0 || head.stdout.trim() !== durabilityReport.headSha) throw new Error("Git HEAD changed during measurement or packaging. No upload was sent; rerun after the checkout is stable.")
|
|
226
|
+
}
|
|
227
|
+
if (!dryRun) buf = readFileSync(packed.archivePath)
|
|
228
|
+
} finally {
|
|
229
|
+
packed?.cleanup()
|
|
230
|
+
coverage.cleanup()
|
|
231
|
+
}
|
|
232
|
+
const localCheckout = { version: 1, history: packed.history, archiveBytes: packed.bytes, durability: durabilityReport ? { source: "ci", windowDays: 90, settleDays: 14, headSha: durabilityReport.headSha, measuredAt: durabilityReport.measuredAt, engineCommit: durabilityReport.engineCommit } : undefined }
|
|
233
|
+
const limitation = "ScaleQuality: historical secret scanning and commit-convention signals are not included in this checkout report."
|
|
234
|
+
console.error(limitation)
|
|
235
|
+
if (dryRun) {
|
|
236
|
+
if (jsonOut) console.log(JSON.stringify({ dryRun: true, checkout: localCheckout, coverageReports: coverage.count }))
|
|
237
|
+
else log("ScaleQuality: local package verified; no upload sent.")
|
|
238
|
+
return
|
|
146
239
|
}
|
|
147
|
-
const buf = readFileSync(archivePath)
|
|
148
|
-
rmSync(archivePath, { force: true })
|
|
149
|
-
const sizeMB = buf.length / (1024 * 1024)
|
|
150
|
-
if (sizeMB > 60) fail(`checkout archive too large (${sizeMB.toFixed(0)}MB > 60MB limit). Reduce clone depth or exclude build output.`)
|
|
151
240
|
|
|
152
241
|
const form = new FormData()
|
|
153
242
|
form.append("project", project)
|
|
243
|
+
form.append("reportProtocol", "durability-ci-v1")
|
|
154
244
|
form.append("file", new Blob([buf]), "checkout.tar.gz")
|
|
155
245
|
const containerReport = scanImage()
|
|
156
246
|
if (containerReport) form.append("containerReport", containerReport)
|
|
@@ -163,7 +253,7 @@ async function main() {
|
|
|
163
253
|
const body = await r.text().catch(() => "")
|
|
164
254
|
if (r.status === 401) fail("invalid token.")
|
|
165
255
|
if (r.status === 402) fail("continuous measurement needs an active subscription.")
|
|
166
|
-
if (r.status === 409) fail("continuous is not enabled for this project. Turn it on in ScaleQuality.")
|
|
256
|
+
if (r.status === 409) fail(body.includes("measurement_already_running") ? "another measurement is already running for this project. Wait for it to finish and rerun this step." : "continuous is not enabled for this project. Turn it on in ScaleQuality.")
|
|
167
257
|
fail(`report failed (${r.status}) ${body}`)
|
|
168
258
|
}
|
|
169
259
|
const { runId } = await r.json()
|
|
@@ -197,6 +287,10 @@ async function main() {
|
|
|
197
287
|
|
|
198
288
|
if (!last || (last.status !== "COMPLETED" && last.status !== "FAILED")) fail("timed out waiting for the measurement.")
|
|
199
289
|
if (last.status === "FAILED") fail(`measurement failed: ${last.failureReason || "unknown"}`)
|
|
290
|
+
if (last.checkout?.version !== 1) fail("the server did not confirm the checkout report protocol. No passing gate is claimed; update to the compatible continuous-measurement server release.")
|
|
291
|
+
if (durabilityReport && (last.checkout?.durability?.source !== "ci" || last.checkout.durability.windowDays !== 90 || last.checkout.durability.settleDays !== 14 || last.checkout.durability.headSha !== durabilityReport.headSha || last.checkout.durability.measuredAt !== durabilityReport.measuredAt || last.checkout.durability.engineCommit !== durabilityReport.engineCommit)) {
|
|
292
|
+
fail("the server did not confirm this checkout's 90-day durability report. No passing gate is claimed; the server needs the compatible continuous-measurement release.")
|
|
293
|
+
}
|
|
200
294
|
|
|
201
295
|
if (jsonOut) {
|
|
202
296
|
console.log(JSON.stringify(last))
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readCommits, listHeadFiles, blameFile } from "./GitReader.mjs";
|
|
2
|
+
import { classifyCommit } from "./OriginClassifier.mjs";
|
|
3
|
+
const DEFAULT_CHURN_DAYS = 14;
|
|
4
|
+
const DEFAULT_MAX_BLAME_FILES = 500;
|
|
5
|
+
const HIGH_VELOCITY_LOC_PER_WEEK = 1500; // "AI-like" cadence threshold
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
8
|
+
function assembleSurvival(introduced, surviving) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const key of new Set([...Object.keys(introduced), ...Object.keys(surviving)])) {
|
|
11
|
+
const intro = introduced[key] ?? 0;
|
|
12
|
+
const surv = surviving[key] ?? 0;
|
|
13
|
+
out[key] = {
|
|
14
|
+
linesIntroduced: intro,
|
|
15
|
+
linesSurviving: Math.min(surv, intro),
|
|
16
|
+
survivalRate: intro > 0 ? Math.min(1, Math.round((surv / intro) * 1000) / 1000) : 0,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
const FIX_RE = /\b(fix|bug|hotfix|patch|revert|broke|regression)\b/i;
|
|
22
|
+
const REVERT_RE = /\brevert(s|ed|ing)?\b|this reverts commit/i;
|
|
23
|
+
function isRevert(c) {
|
|
24
|
+
return REVERT_RE.test(c.body);
|
|
25
|
+
}
|
|
26
|
+
/** Churn: a file touched again within churnDays of a prior touch. */
|
|
27
|
+
function computeChurn(commits, churnDays) {
|
|
28
|
+
const fileTouches = new Map();
|
|
29
|
+
for (const c of commits) {
|
|
30
|
+
const t = Date.parse(c.date);
|
|
31
|
+
const perFile = c.files.length > 0 ? (c.additions + c.deletions) / c.files.length : 0;
|
|
32
|
+
for (const f of c.files) {
|
|
33
|
+
if (!fileTouches.has(f))
|
|
34
|
+
fileTouches.set(f, []);
|
|
35
|
+
fileTouches.get(f).push({ t, addDel: perFile });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let churnLinesAffected = 0;
|
|
39
|
+
let churnEvents = 0;
|
|
40
|
+
let filesWithNew = 0;
|
|
41
|
+
let filesReChurned2w = 0;
|
|
42
|
+
const TWO_WEEKS = 14 * DAY_MS;
|
|
43
|
+
for (const touches of fileTouches.values()) {
|
|
44
|
+
touches.sort((a, b) => a.t - b.t);
|
|
45
|
+
filesWithNew++;
|
|
46
|
+
let reChurned2w = false;
|
|
47
|
+
for (let i = 1; i < touches.length; i++) {
|
|
48
|
+
const gap = Math.abs(touches[i].t - touches[i - 1].t);
|
|
49
|
+
if (gap <= churnDays * DAY_MS) {
|
|
50
|
+
churnEvents++;
|
|
51
|
+
churnLinesAffected += Math.round(touches[i].addDel);
|
|
52
|
+
}
|
|
53
|
+
if (touches[i].t - touches[0].t <= TWO_WEEKS)
|
|
54
|
+
reChurned2w = true;
|
|
55
|
+
}
|
|
56
|
+
if (reChurned2w)
|
|
57
|
+
filesReChurned2w++;
|
|
58
|
+
}
|
|
59
|
+
const newCodeChurn2w = filesWithNew > 0 ? Math.round((filesReChurned2w / filesWithNew) * 1000) / 1000 : 0;
|
|
60
|
+
return { churnLinesAffected, churnEvents, newCodeChurn2w };
|
|
61
|
+
}
|
|
62
|
+
/** Cascade: fix-commits that closely follow a prior commit on shared files. */
|
|
63
|
+
function computeCascadeRate(commits, churnDays) {
|
|
64
|
+
if (commits.length === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
const sorted = [...commits].sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
|
|
67
|
+
let cascades = 0;
|
|
68
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
69
|
+
if (!FIX_RE.test(sorted[i].body))
|
|
70
|
+
continue;
|
|
71
|
+
const ti = Date.parse(sorted[i].date);
|
|
72
|
+
const fi = new Set(sorted[i].files);
|
|
73
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
74
|
+
if (ti - Date.parse(sorted[j].date) > churnDays * DAY_MS)
|
|
75
|
+
break;
|
|
76
|
+
if (sorted[j].files.some((f) => fi.has(f))) {
|
|
77
|
+
cascades++;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return Math.round((cascades / sorted.length) * 1000) / 1000;
|
|
83
|
+
}
|
|
84
|
+
/** Stabilization: share of touched files touched exactly once (settled). */
|
|
85
|
+
function computeStabilization(commits) {
|
|
86
|
+
const counts = new Map();
|
|
87
|
+
for (const c of commits)
|
|
88
|
+
for (const f of c.files)
|
|
89
|
+
counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
90
|
+
if (counts.size === 0)
|
|
91
|
+
return 0;
|
|
92
|
+
let stabilized = 0;
|
|
93
|
+
for (const n of counts.values())
|
|
94
|
+
if (n === 1)
|
|
95
|
+
stabilized++;
|
|
96
|
+
return Math.round((stabilized / counts.size) * 1000) / 1000;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Attribution gap (directional): authors writing at AI-like velocity
|
|
100
|
+
* (>threshold loc/week) but with low AI attribution → suspected
|
|
101
|
+
* unattributed AI. Never falsely re-classifies a commit; this is a flag.
|
|
102
|
+
*/
|
|
103
|
+
function computeAttributionGap(commits, windowDays) {
|
|
104
|
+
const weeks = Math.max(1, windowDays / 7);
|
|
105
|
+
const byAuthor = new Map();
|
|
106
|
+
for (const c of commits) {
|
|
107
|
+
if (c.origin === "BOT")
|
|
108
|
+
continue;
|
|
109
|
+
const a = byAuthor.get(c.email) ?? { total: 0, ai: 0, loc: 0 };
|
|
110
|
+
a.total++;
|
|
111
|
+
if (c.origin === "AI_ATTRIBUTED")
|
|
112
|
+
a.ai++;
|
|
113
|
+
a.loc += c.additions + c.deletions;
|
|
114
|
+
byAuthor.set(c.email, a);
|
|
115
|
+
}
|
|
116
|
+
let suspected = 0;
|
|
117
|
+
let total = 0;
|
|
118
|
+
for (const a of byAuthor.values()) {
|
|
119
|
+
total += a.total;
|
|
120
|
+
const aiPct = a.total > 0 ? a.ai / a.total : 0;
|
|
121
|
+
const locPerWeek = a.loc / weeks;
|
|
122
|
+
if (locPerWeek > HIGH_VELOCITY_LOC_PER_WEEK && aiPct < 0.5) {
|
|
123
|
+
suspected += a.total * (1 - aiPct); // the non-attributed share
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const pct = total > 0 ? Math.round((suspected / total) * 100) : 0;
|
|
127
|
+
const confidence = "LOW";
|
|
128
|
+
return { suspectedAttributionGapPct: pct, attributionConfidence: confidence };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Native durability engine. Analyses a cloned repo's git history (log +
|
|
132
|
+
* blame) and returns durability/origin/churn/attribution metrics by
|
|
133
|
+
* origin and tool. Deterministic, no LLM, no external engine.
|
|
134
|
+
*/
|
|
135
|
+
export async function analyzeDurability(repoPath, opts) {
|
|
136
|
+
const churnDays = opts.churnDays ?? DEFAULT_CHURN_DAYS;
|
|
137
|
+
const maxBlame = opts.maxBlameFiles ?? DEFAULT_MAX_BLAME_FILES;
|
|
138
|
+
const log = opts.onLog ?? (() => { });
|
|
139
|
+
const endDaysAgo = opts.endDaysAgo ?? 0;
|
|
140
|
+
const settleDays = opts.settleDays ?? 0;
|
|
141
|
+
const raw = await readCommits(repoPath, opts.windowDays + endDaysAgo, endDaysAgo);
|
|
142
|
+
const commits = raw.map((c) => ({ ...c, ...classifyCommit(c) }));
|
|
143
|
+
log(`commits in window: ${commits.length}`);
|
|
144
|
+
// Survival is measured ONLY on commits old enough to have settled (older than
|
|
145
|
+
// settleDays). Code written in the last settleDays is right-censored — it
|
|
146
|
+
// hasn't had time to be reverted, so counting it would inflate survival.
|
|
147
|
+
// Churn, revert, cascade and attribution stay on the full window.
|
|
148
|
+
const settleCutoff = Date.now() - settleDays * DAY_MS;
|
|
149
|
+
const measureCommits = settleDays > 0
|
|
150
|
+
? commits.filter((c) => Date.parse(c.date) <= settleCutoff)
|
|
151
|
+
: commits;
|
|
152
|
+
const measureShas = new Map(measureCommits.map((c) => [c.sha, c]));
|
|
153
|
+
const introducedByOrigin = {};
|
|
154
|
+
const introducedByTool = {};
|
|
155
|
+
for (const c of measureCommits) {
|
|
156
|
+
introducedByOrigin[c.origin] = (introducedByOrigin[c.origin] ?? 0) + c.additions;
|
|
157
|
+
if (c.tool)
|
|
158
|
+
introducedByTool[c.tool] = (introducedByTool[c.tool] ?? 0) + c.additions;
|
|
159
|
+
}
|
|
160
|
+
const headFiles = await listHeadFiles(repoPath);
|
|
161
|
+
const touched = new Set();
|
|
162
|
+
for (const c of commits)
|
|
163
|
+
for (const f of c.files)
|
|
164
|
+
if (headFiles.has(f))
|
|
165
|
+
touched.add(f);
|
|
166
|
+
let files = [...touched];
|
|
167
|
+
if (files.length > maxBlame) {
|
|
168
|
+
log(`blame capped: ${maxBlame}/${files.length} files (perf)`);
|
|
169
|
+
files = files.slice(0, maxBlame);
|
|
170
|
+
}
|
|
171
|
+
log(`blaming ${files.length} files...`);
|
|
172
|
+
const survivingByOrigin = {};
|
|
173
|
+
const survivingByTool = {};
|
|
174
|
+
for (const f of files) {
|
|
175
|
+
const counts = await blameFile(repoPath, f);
|
|
176
|
+
for (const [sha, n] of counts) {
|
|
177
|
+
const c = measureShas.get(sha);
|
|
178
|
+
if (!c)
|
|
179
|
+
continue; // line introduced outside the settled measurement window
|
|
180
|
+
survivingByOrigin[c.origin] = (survivingByOrigin[c.origin] ?? 0) + n;
|
|
181
|
+
if (c.tool)
|
|
182
|
+
survivingByTool[c.tool] = (survivingByTool[c.tool] ?? 0) + n;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const byOrigin = assembleSurvival(introducedByOrigin, survivingByOrigin);
|
|
186
|
+
const byTool = assembleSurvival(introducedByTool, survivingByTool);
|
|
187
|
+
const linesIntroduced = sum(Object.values(introducedByOrigin));
|
|
188
|
+
// Clamp: a surviving line was, by definition, introduced — survival
|
|
189
|
+
// can't exceed 100% (guards against any blame/window edge).
|
|
190
|
+
const linesSurviving = Math.min(sum(Object.values(survivingByOrigin)), linesIntroduced);
|
|
191
|
+
const survivalRate = linesIntroduced > 0 ? linesSurviving / linesIntroduced : 0;
|
|
192
|
+
const { churnLinesAffected, newCodeChurn2w } = computeChurn(commits, churnDays);
|
|
193
|
+
const cascadeRate = computeCascadeRate(commits, churnDays);
|
|
194
|
+
const stabilizationRatio = computeStabilization(commits);
|
|
195
|
+
const revertCount = commits.filter(isRevert).length;
|
|
196
|
+
const revertRate = commits.length > 0 ? revertCount / commits.length : 0;
|
|
197
|
+
const aiCommits = commits.filter((c) => c.origin === "AI_ATTRIBUTED").length;
|
|
198
|
+
const nonBot = commits.filter((c) => c.origin !== "BOT").length;
|
|
199
|
+
const aiDetectionCoveragePct = nonBot > 0 ? Math.round((aiCommits / nonBot) * 100) : 0;
|
|
200
|
+
const { suspectedAttributionGapPct, attributionConfidence } = computeAttributionGap(commits, opts.windowDays);
|
|
201
|
+
const durablePct = Math.round(survivalRate * 100);
|
|
202
|
+
const revertPct = Math.round(revertRate * 100);
|
|
203
|
+
const churnPct = Math.max(0, 100 - durablePct - revertPct);
|
|
204
|
+
const signalToNoise = churnLinesAffected > 0 ? Math.round((linesSurviving / churnLinesAffected) * 100) / 100 : null;
|
|
205
|
+
return {
|
|
206
|
+
commitsTotal: commits.length,
|
|
207
|
+
linesIntroduced,
|
|
208
|
+
linesSurviving,
|
|
209
|
+
survivalRate: Math.round(survivalRate * 1000) / 1000,
|
|
210
|
+
stabilizationRatio,
|
|
211
|
+
churnLinesAffected,
|
|
212
|
+
revertRate: Math.round(revertRate * 1000) / 1000,
|
|
213
|
+
newCodeChurn2w,
|
|
214
|
+
cascadeRate,
|
|
215
|
+
aiDetectionCoveragePct,
|
|
216
|
+
durablePct,
|
|
217
|
+
churnPct,
|
|
218
|
+
revertPct,
|
|
219
|
+
signalToNoise,
|
|
220
|
+
suspectedAttributionGapPct,
|
|
221
|
+
attributionConfidence,
|
|
222
|
+
byOrigin,
|
|
223
|
+
byTool,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { runGit as git, isFatalGitError } from "./runtime.mjs";
|
|
2
|
+
const US = "\x1f"; // unit separator (field)
|
|
3
|
+
const RS = "\x1e"; // record separator (commit)
|
|
4
|
+
const CO_AUTHOR_RE = /co-authored-by:\s*(.*?)\s*<([^>]+)>/gi;
|
|
5
|
+
function parseCoAuthors(body) {
|
|
6
|
+
const out = [];
|
|
7
|
+
let m;
|
|
8
|
+
CO_AUTHOR_RE.lastIndex = 0;
|
|
9
|
+
while ((m = CO_AUTHOR_RE.exec(body)) !== null) {
|
|
10
|
+
out.push({ name: m[1].trim(), email: m[2].trim().toLowerCase() });
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reads commits in the window (metadata + co-authors via one log call,
|
|
16
|
+
* file stats via a second --numstat call) and merges them. Two calls so
|
|
17
|
+
* the multi-line commit body can't corrupt numstat line parsing.
|
|
18
|
+
*/
|
|
19
|
+
export async function readCommits(repoPath, sinceDays, untilDays = 0) {
|
|
20
|
+
const since = `${sinceDays} days ago`;
|
|
21
|
+
// untilDays > 0 bounds the window's END in the past (point-in-time backfill).
|
|
22
|
+
const untilArgs = untilDays > 0 ? [`--until=${untilDays} days ago`] : [];
|
|
23
|
+
// Pass 1: metadata + body (for co-author trailers).
|
|
24
|
+
const metaFmt = ["%H", "%an", "%ae", "%cI", "%B"].join(US) + RS;
|
|
25
|
+
const metaOut = await git(repoPath, [
|
|
26
|
+
"log",
|
|
27
|
+
`--since=${since}`,
|
|
28
|
+
...untilArgs,
|
|
29
|
+
"--no-merges",
|
|
30
|
+
`--pretty=format:${metaFmt}`,
|
|
31
|
+
]);
|
|
32
|
+
const bySha = new Map();
|
|
33
|
+
for (const rec of metaOut.split(RS)) {
|
|
34
|
+
const trimmed = rec.replace(/^\n+/, "");
|
|
35
|
+
if (!trimmed.trim())
|
|
36
|
+
continue;
|
|
37
|
+
const parts = trimmed.split(US);
|
|
38
|
+
if (parts.length < 5)
|
|
39
|
+
continue;
|
|
40
|
+
const [sha, author, email, date, body] = parts;
|
|
41
|
+
bySha.set(sha, {
|
|
42
|
+
sha,
|
|
43
|
+
author,
|
|
44
|
+
email: email.toLowerCase(),
|
|
45
|
+
date,
|
|
46
|
+
body,
|
|
47
|
+
coAuthors: parseCoAuthors(body),
|
|
48
|
+
additions: 0,
|
|
49
|
+
deletions: 0,
|
|
50
|
+
files: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Pass 2: per-commit numstat.
|
|
54
|
+
const statOut = await git(repoPath, [
|
|
55
|
+
"log",
|
|
56
|
+
`--since=${since}`,
|
|
57
|
+
...untilArgs,
|
|
58
|
+
"--no-merges",
|
|
59
|
+
"--numstat",
|
|
60
|
+
`--pretty=format:${RS}%H`,
|
|
61
|
+
]);
|
|
62
|
+
for (const block of statOut.split(RS)) {
|
|
63
|
+
if (!block.trim())
|
|
64
|
+
continue;
|
|
65
|
+
const lines = block.split("\n").filter((l) => l.length > 0);
|
|
66
|
+
const sha = lines.shift()?.trim();
|
|
67
|
+
if (!sha)
|
|
68
|
+
continue;
|
|
69
|
+
const rec = bySha.get(sha);
|
|
70
|
+
if (!rec)
|
|
71
|
+
continue;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const cols = line.split("\t");
|
|
74
|
+
if (cols.length < 3)
|
|
75
|
+
continue;
|
|
76
|
+
const add = Number.parseInt(cols[0], 10); // "-" (binary) → NaN
|
|
77
|
+
const del = Number.parseInt(cols[1], 10);
|
|
78
|
+
rec.additions += Number.isFinite(add) ? add : 0;
|
|
79
|
+
rec.deletions += Number.isFinite(del) ? del : 0;
|
|
80
|
+
rec.files.push(cols.slice(2).join("\t"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...bySha.values()];
|
|
84
|
+
}
|
|
85
|
+
/** Files that currently exist at HEAD (tracked). */
|
|
86
|
+
export async function listHeadFiles(repoPath) {
|
|
87
|
+
const out = await git(repoPath, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
88
|
+
return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
|
|
89
|
+
}
|
|
90
|
+
const SHA_LINE = /^([0-9a-f]{40}) /;
|
|
91
|
+
/**
|
|
92
|
+
* Blames a file at HEAD and returns, per introducing commit sha, how many
|
|
93
|
+
* CURRENT lines it owns (--line-porcelain → one sha header per line). Used
|
|
94
|
+
* to compute "lines introduced in window that still survive". Returns an
|
|
95
|
+
* empty map on any blame failure (deleted/binary/huge file).
|
|
96
|
+
*/
|
|
97
|
+
export async function blameFile(repoPath, file) {
|
|
98
|
+
const counts = new Map();
|
|
99
|
+
try {
|
|
100
|
+
const out = await git(repoPath, [
|
|
101
|
+
"blame",
|
|
102
|
+
"--line-porcelain",
|
|
103
|
+
"HEAD",
|
|
104
|
+
"--",
|
|
105
|
+
file,
|
|
106
|
+
]);
|
|
107
|
+
for (const line of out.split("\n")) {
|
|
108
|
+
const m = SHA_LINE.exec(line);
|
|
109
|
+
if (m)
|
|
110
|
+
counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (isFatalGitError(error)) throw error;
|
|
115
|
+
// deleted at HEAD / binary / unreadable → no surviving lines counted
|
|
116
|
+
}
|
|
117
|
+
return counts;
|
|
118
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native origin classifier (our own). Deterministic, confidence-aware.
|
|
3
|
+
*
|
|
4
|
+
* Signals (high confidence): Co-Authored-By trailers + author identity
|
|
5
|
+
* matching known AI tools / bots. NO content analysis, NO false
|
|
6
|
+
* attribution — a commit without an AI signal is HUMAN. Unattributed-but-
|
|
7
|
+
* likely-AI is surfaced separately by AttributionGapAnalyzer (SUSPECTED),
|
|
8
|
+
* never silently classified here.
|
|
9
|
+
*/
|
|
10
|
+
const AI_TOOL_PATTERNS = [
|
|
11
|
+
[/copilot|github-copilot/i, "Copilot"],
|
|
12
|
+
[/claude|anthropic/i, "Claude"],
|
|
13
|
+
[/cursor/i, "Cursor"],
|
|
14
|
+
[/codeium/i, "Codeium"],
|
|
15
|
+
[/tabnine/i, "Tabnine"],
|
|
16
|
+
[/amazon[-\s]?q/i, "Amazon Q"],
|
|
17
|
+
[/gemini/i, "Gemini"],
|
|
18
|
+
[/windsurf/i, "Windsurf"],
|
|
19
|
+
[/devin/i, "Devin"],
|
|
20
|
+
[/aider/i, "Aider"],
|
|
21
|
+
];
|
|
22
|
+
const BOT_PATTERN = /\[bot\]|-bot\b|\bbot@|dependabot|renovate|github-actions|mergify|snyk-bot|copilot-autofix/i;
|
|
23
|
+
function matchTool(haystack) {
|
|
24
|
+
for (const [re, tool] of AI_TOOL_PATTERNS) {
|
|
25
|
+
if (re.test(haystack))
|
|
26
|
+
return tool;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
export function classifyCommit(c) {
|
|
31
|
+
// 1. Co-author trailers → strongest AI signal.
|
|
32
|
+
for (const ca of c.coAuthors) {
|
|
33
|
+
const tool = matchTool(`${ca.email} ${ca.name}`);
|
|
34
|
+
if (tool)
|
|
35
|
+
return { origin: "AI_ATTRIBUTED", tool, confidence: "HIGH" };
|
|
36
|
+
}
|
|
37
|
+
// 2. Author identity is itself an AI tool (e.g. copilot@github.com).
|
|
38
|
+
const authorTool = matchTool(`${c.email} ${c.author}`);
|
|
39
|
+
if (authorTool && !BOT_PATTERN.test(`${c.author} ${c.email}`)) {
|
|
40
|
+
return { origin: "AI_ATTRIBUTED", tool: authorTool, confidence: "HIGH" };
|
|
41
|
+
}
|
|
42
|
+
// 3. Bot.
|
|
43
|
+
if (BOT_PATTERN.test(`${c.author} ${c.email}`)) {
|
|
44
|
+
return { origin: "BOT", tool: null, confidence: "HIGH" };
|
|
45
|
+
}
|
|
46
|
+
// 4. Default human.
|
|
47
|
+
return { origin: "HUMAN", tool: null, confidence: "HIGH" };
|
|
48
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Bundled local Git measurement
|
|
2
|
+
|
|
3
|
+
The four generated modules come from `scalequality-ai-governance-service` commit
|
|
4
|
+
`35a9ac72e2a131c108b40c9d2477f58a90deb4c1`, under
|
|
5
|
+
`src/application/services/durability/`. `provenance.json` records original SHA-256
|
|
6
|
+
hashes, compiler settings, generated hashes, and the source-manifest digest exported
|
|
7
|
+
by `../durability.mjs`.
|
|
8
|
+
|
|
9
|
+
To regenerate, extract the four named TypeScript files from that exact Git commit
|
|
10
|
+
into a temporary directory. Compile with TypeScript 5.9.3, target/module ES2022,
|
|
11
|
+
moduleResolution node, strict true, esModuleInterop true, skipLibCheck true, and
|
|
12
|
+
Node type declarations. No dependencies are needed by the resulting runtime.
|
|
13
|
+
Rename the generated files to `.mjs` and give their relative imports `.mjs`
|
|
14
|
+
extensions. The engine's calculations and classifier remain unchanged. In the
|
|
15
|
+
reader, replace its promisified `execFile` wrapper with `runtime.mjs`'s `runGit`,
|
|
16
|
+
and rethrow `isFatalGitError(error)` in its per-file blame catch. Retain native
|
|
17
|
+
handling for other per-file errors. Recompute provenance hashes and run the
|
|
18
|
+
parity and cancellation tests. The reference modules under
|
|
19
|
+
`test/native-durability-reference/` preserve the original reader for small fixture
|
|
20
|
+
comparisons only; they are excluded from the published package.
|
|
21
|
+
|
|
22
|
+
The runtime keeps the native 90-day window, 14-day settlement and churn settings,
|
|
23
|
+
and native maximum of 500 blamed files. A native cap warning is logged; this does
|
|
24
|
+
not certify that every tracked file was blamed. Tracked dependency files remain
|
|
25
|
+
part of native Git measurement even if archive exclusions omit those directories.
|
|
26
|
+
|
|
27
|
+
No clone, fetch, checkout or Git write is performed. Shallow or partial histories,
|
|
28
|
+
missing objects, changing HEAD and invalid reports produce no measurement.
|
|
29
|
+
The separate worker has a 120-second total budget and 512 MiB V8 heap ceiling;
|
|
30
|
+
each Git command has at most 30 seconds and 16 MiB of output. The complete HEAD
|
|
31
|
+
object inventory uses this same limit, so sufficiently large object inventories
|
|
32
|
+
can be unavailable even when the 90-day change window is small. Limit failures
|
|
33
|
+
are reported as unavailable, never converted into successful zero measurements.
|
|
34
|
+
Only the measurement worker's own process group and Git children are cleaned up
|
|
35
|
+
on cancellation or parent IPC disconnection. Git command output, author details,
|
|
36
|
+
credentials and customer environment tokens are not logged or sent to the worker.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"engineCommit": "35a9ac72e2a131c108b40c9d2477f58a90deb4c1",
|
|
3
|
+
"sourceRepository": "scalequality-ai-governance-service",
|
|
4
|
+
"sourceFiles": [
|
|
5
|
+
{
|
|
6
|
+
"file": "src/application/services/durability/GitDurabilityEngine.ts",
|
|
7
|
+
"sha256": "4210aba0a7dbfa6ca62178ff434dcaef81b0ca77c72a8fa488ae48f03c734105"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"file": "src/application/services/durability/GitReader.ts",
|
|
11
|
+
"sha256": "bc6ca21db40bbdadebdccfbfb7f3dcab3125eeed96d2199a6bd309c949410e9b"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"file": "src/application/services/durability/OriginClassifier.ts",
|
|
15
|
+
"sha256": "ac58ba1684b63ee34c168b39574f777087fadebe9ce80e2c1da8948f7bb3bd62"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"file": "src/application/services/durability/types.ts",
|
|
19
|
+
"sha256": "f102fabbeb0a3eee09ca0be64bb7777f12c553c91ebd615340132e674ea66026"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"sourceManifestSha256": "bc7a01ce93df3aad2f80da281934868ba7327a1a04fad840f35ecc8b422c74cf",
|
|
23
|
+
"compiler": {
|
|
24
|
+
"version": "5.9.3",
|
|
25
|
+
"target": "ES2022",
|
|
26
|
+
"module": "ES2022",
|
|
27
|
+
"strictTypecheckPassed": true
|
|
28
|
+
},
|
|
29
|
+
"runtimeChanges": [
|
|
30
|
+
"ESM relative import extensions only in engine/classifier",
|
|
31
|
+
"GitReader process wrapper replaced by bounded local-only runner",
|
|
32
|
+
"GitReader blame catch rethrows resource/incomplete-history failures; ordinary per-file errors retain native handling"
|
|
33
|
+
],
|
|
34
|
+
"generatedFiles": [
|
|
35
|
+
{
|
|
36
|
+
"file": "GitDurabilityEngine.mjs",
|
|
37
|
+
"sha256": "7aee834f36a2737f1e7dd459ed067653c264a5d46ce5d602ed3fbba24ae1cc8a"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"file": "GitReader.mjs",
|
|
41
|
+
"sha256": "447ce620849adc44e3abdf153174ce485ef5391cb29a00fc2e1d9a3064197b96"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"file": "OriginClassifier.mjs",
|
|
45
|
+
"sha256": "f1da36019423b8e60ad9c9c69b690c55fde4333de27dd3ee08a95ec97f09d7b7"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"file": "types.mjs",
|
|
49
|
+
"sha256": "50401f50b011535140ddc298f3b532cc931a0039247b96e64e7352c04c022723"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export const MAX_GIT_BUFFER = 16 * 1024 * 1024
|
|
4
|
+
export const MAX_GIT_TIME_MS = 30_000
|
|
5
|
+
let deadline = 0
|
|
6
|
+
const children = new Set()
|
|
7
|
+
|
|
8
|
+
export class GitMeasurementError extends Error {
|
|
9
|
+
constructor(reason) { super(reason); this.reason = reason; this.fatalMeasurement = true }
|
|
10
|
+
}
|
|
11
|
+
export const isFatalGitError = (error) => error?.fatalMeasurement === true
|
|
12
|
+
export function setDeadline(value) { deadline = value }
|
|
13
|
+
export function remainingTime() {
|
|
14
|
+
const remaining = deadline - Date.now()
|
|
15
|
+
if (remaining <= 0) throw new GitMeasurementError('time budget exceeded')
|
|
16
|
+
return remaining
|
|
17
|
+
}
|
|
18
|
+
export function stopGitChildren() {
|
|
19
|
+
for (const child of children) { try { child.kill('SIGKILL') } catch {} }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function localGitEnvironment() {
|
|
23
|
+
const env = {}
|
|
24
|
+
for (const name of ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TMPDIR', 'TMP', 'TEMP']) {
|
|
25
|
+
if (process.env[name] !== undefined) env[name] = process.env[name]
|
|
26
|
+
}
|
|
27
|
+
return { ...env, LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runGit(repoPath, args, { allowMissingConfig = false } = {}) {
|
|
31
|
+
const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = execFile('git', [
|
|
34
|
+
'--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'),
|
|
35
|
+
'-c', 'credential.helper=', '-c', 'protocol.allow=never', '-c', 'protocol.file.allow=never',
|
|
36
|
+
'-C', repoPath, ...args,
|
|
37
|
+
], { encoding: 'utf8', maxBuffer: MAX_GIT_BUFFER, timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment() }, (error, stdout, stderr) => {
|
|
38
|
+
children.delete(child)
|
|
39
|
+
if (!error) { try { remainingTime(); resolve(stdout) } catch (e) { reject(e) }; return }
|
|
40
|
+
if (allowMissingConfig && error.code === 1) { resolve(''); return }
|
|
41
|
+
if (error.code === 'ENOENT') { reject(new GitMeasurementError('Git is unavailable')); return }
|
|
42
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS') { reject(new GitMeasurementError('Git output exceeded the 16 MiB resource limit')); return }
|
|
43
|
+
if (error.killed || error.signal || error.code === 'ETIMEDOUT') { reject(new GitMeasurementError('Git command exceeded the time budget')); return }
|
|
44
|
+
if (/missing (?:blob|tree|commit|object)|bad object|unable to read|could not read|object .*corrupt|object file .*empty|invalid object|promisor|transport .*not allowed/i.test(stderr || '')) {
|
|
45
|
+
reject(new GitMeasurementError('Git history is incomplete or unavailable locally')); return
|
|
46
|
+
}
|
|
47
|
+
// Preserve the native reader's handling of ordinary per-file blame errors.
|
|
48
|
+
// Never propagate command output, author identities or credentials to logs.
|
|
49
|
+
const safeError = new Error('Git command failed')
|
|
50
|
+
safeError.code = error.code
|
|
51
|
+
reject(safeError)
|
|
52
|
+
})
|
|
53
|
+
children.add(child)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const ENGINE_COMMIT = '35a9ac72e2a131c108b40c9d2477f58a90deb4c1'
|
|
2
|
+
const COUNTS = ['commitsTotal', 'linesIntroduced', 'linesSurviving', 'churnLinesAffected']
|
|
3
|
+
const RATIOS = ['survivalRate', 'stabilizationRatio', 'revertRate', 'newCodeChurn2w', 'cascadeRate']
|
|
4
|
+
const PCTS = ['aiDetectionCoveragePct', 'durablePct', 'churnPct', 'revertPct', 'suspectedAttributionGapPct']
|
|
5
|
+
const TOOLS = ['Copilot', 'Claude', 'Cursor', 'Codeium', 'Tabnine', 'Amazon Q', 'Gemini', 'Windsurf', 'Devin', 'Aider']
|
|
6
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
7
|
+
const keys = (v, names) => object(v) && Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k))
|
|
8
|
+
const count = (v) => Number.isSafeInteger(v) && v >= 0
|
|
9
|
+
const ratio = (v) => Number.isFinite(v) && v >= 0 && v <= 1
|
|
10
|
+
const roundedRatio = (surviving, introduced) => introduced > 0 ? Math.round(surviving / introduced * 1000) / 1000 : 0
|
|
11
|
+
function survivalMap(map, allowed) {
|
|
12
|
+
return object(map) && Object.entries(map).every(([key, v]) => allowed.includes(key) && keys(v, ['linesIntroduced', 'linesSurviving', 'survivalRate']) && count(v.linesIntroduced) && count(v.linesSurviving) && ratio(v.survivalRate) && v.linesSurviving <= v.linesIntroduced && v.survivalRate === roundedRatio(v.linesSurviving, v.linesIntroduced))
|
|
13
|
+
}
|
|
14
|
+
export function validateReport(report, now = Date.now()) {
|
|
15
|
+
try {
|
|
16
|
+
if (!keys(report, ['version', 'source', 'engineCommit', 'windowDays', 'settleDays', 'headSha', 'measuredAt', 'result'])) return false
|
|
17
|
+
if (report.version !== 1 || report.source !== 'ci' || report.engineCommit !== ENGINE_COMMIT || report.windowDays !== 90 || report.settleDays !== 14 || !/^[0-9a-f]{40}$/.test(report.headSha)) return false
|
|
18
|
+
if (typeof report.measuredAt !== 'string' || !Number.isFinite(Date.parse(report.measuredAt)) || Math.abs(now - Date.parse(report.measuredAt)) > 86_400_000) return false
|
|
19
|
+
const r = report.result
|
|
20
|
+
if (!keys(r, [...COUNTS, ...RATIOS, ...PCTS, 'signalToNoise', 'attributionConfidence', 'byOrigin', 'byTool'])) return false
|
|
21
|
+
if (!COUNTS.every(k => count(r[k])) || !RATIOS.every(k => ratio(r[k])) || !PCTS.every(k => count(r[k]) && r[k] <= 100)) return false
|
|
22
|
+
if (!(r.signalToNoise === null || (Number.isFinite(r.signalToNoise) && r.signalToNoise >= 0)) || r.attributionConfidence !== 'LOW') return false
|
|
23
|
+
if (!survivalMap(r.byOrigin, ['AI_ATTRIBUTED', 'BOT', 'HUMAN']) || !survivalMap(r.byTool, TOOLS)) return false
|
|
24
|
+
const sum = (map, key) => Object.values(map).reduce((n, v) => n + v[key], 0)
|
|
25
|
+
if (sum(r.byOrigin, 'linesIntroduced') !== r.linesIntroduced || sum(r.byOrigin, 'linesSurviving') > r.linesSurviving || r.linesSurviving > r.linesIntroduced || r.survivalRate !== roundedRatio(r.linesSurviving, r.linesIntroduced)) return false
|
|
26
|
+
if (r.durablePct !== (r.linesIntroduced > 0 ? Math.round(r.linesSurviving / r.linesIntroduced * 100) : 0) || Math.abs(r.revertPct - r.revertRate * 100) > 0.55 || r.churnPct !== Math.max(0, 100 - r.durablePct - r.revertPct)) return false
|
|
27
|
+
if (sum(r.byTool, 'linesIntroduced') !== (r.byOrigin.AI_ATTRIBUTED?.linesIntroduced ?? 0) || sum(r.byTool, 'linesSurviving') > (r.byOrigin.AI_ATTRIBUTED?.linesSurviving ?? 0)) return false
|
|
28
|
+
if (r.signalToNoise !== (r.churnLinesAffected > 0 ? Math.round(r.linesSurviving / r.churnLinesAffected * 100) / 100 : null)) return false
|
|
29
|
+
return Buffer.byteLength(JSON.stringify(report)) <= 1024 * 1024
|
|
30
|
+
} catch { return false }
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises'
|
|
2
|
+
import { analyzeDurability } from './GitDurabilityEngine.mjs'
|
|
3
|
+
import { ENGINE_COMMIT, validateReport } from './validate.mjs'
|
|
4
|
+
import { runGit, setDeadline, remainingTime, stopGitChildren, GitMeasurementError } from './runtime.mjs'
|
|
5
|
+
|
|
6
|
+
process.on('SIGTERM', () => { stopGitChildren(); process.exit(2) })
|
|
7
|
+
let completed = false
|
|
8
|
+
process.once('disconnect', () => {
|
|
9
|
+
if (!completed) { stopGitChildren(); process.exit(2) }
|
|
10
|
+
})
|
|
11
|
+
const send = (message) => { if (process.connected) process.send(message) }
|
|
12
|
+
process.once('message', async ({ root, deadline }) => {
|
|
13
|
+
try {
|
|
14
|
+
setDeadline(deadline)
|
|
15
|
+
if ((await runGit(root, ['rev-parse', '--is-inside-work-tree'])).trim() !== 'true') throw new GitMeasurementError('checkout has no working Git history')
|
|
16
|
+
const top = (await runGit(root, ['rev-parse', '--show-toplevel'])).trim()
|
|
17
|
+
if (await realpath(top) !== await realpath(root)) throw new GitMeasurementError('run the check from the repository root to measure Git history')
|
|
18
|
+
if ((await runGit(root, ['rev-parse', '--is-shallow-repository'])).trim() !== 'false') throw new GitMeasurementError('shallow Git history cannot provide the full 90-day measurement')
|
|
19
|
+
const partial = await runGit(root, ['config', '--get-regexp', '^(extensions\.partialclone|remote\..*\.promisor)$'], { allowMissingConfig: true })
|
|
20
|
+
if (partial.split('\n').some(line => /^extensions\.partialclone\s+\S/i.test(line) || /^remote\..*\.promisor\s+(true|yes|on|1)\s*$/i.test(line))) throw new GitMeasurementError('partial Git history cannot be measured without fetching objects')
|
|
21
|
+
const headSha = (await runGit(root, ['rev-parse', '--verify', 'HEAD'])).trim()
|
|
22
|
+
if (!/^[0-9a-f]{40}$/.test(headSha)) throw new GitMeasurementError('a valid SHA-1 Git HEAD is required')
|
|
23
|
+
const objects = await runGit(root, ['rev-list', '--objects', '--no-object-names', '--missing=print', 'HEAD'])
|
|
24
|
+
if (objects.split('\n').some(line => /^\?[0-9a-f]+$/.test(line))) throw new GitMeasurementError('Git history is missing local objects')
|
|
25
|
+
const measuredAt = new Date().toISOString()
|
|
26
|
+
const result = await analyzeDurability(root, {
|
|
27
|
+
windowDays: 90, settleDays: 14,
|
|
28
|
+
onLog: message => { remainingTime(); send({ type: 'log', message }) },
|
|
29
|
+
})
|
|
30
|
+
remainingTime()
|
|
31
|
+
if ((await runGit(root, ['rev-parse', '--verify', 'HEAD'])).trim() !== headSha) throw new GitMeasurementError('Git HEAD changed during measurement')
|
|
32
|
+
const report = { version: 1, source: 'ci', engineCommit: ENGINE_COMMIT, windowDays: 90, settleDays: 14, headSha, measuredAt, result }
|
|
33
|
+
if (!validateReport(report)) throw new GitMeasurementError('native measurement did not pass report validation')
|
|
34
|
+
send({ type: 'result', report })
|
|
35
|
+
completed = true
|
|
36
|
+
process.disconnect()
|
|
37
|
+
} catch (error) {
|
|
38
|
+
send({ type: 'failure', reason: error instanceof GitMeasurementError ? error.reason : 'local Git measurement failed' })
|
|
39
|
+
stopGitChildren()
|
|
40
|
+
process.exitCode = 2
|
|
41
|
+
completed = true
|
|
42
|
+
if (process.connected) process.disconnect()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { fork } from 'node:child_process'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { validateReport } from './durability/validate.mjs'
|
|
5
|
+
import { localGitEnvironment } from './durability/runtime.mjs'
|
|
6
|
+
export { ENGINE_COMMIT } from './durability/validate.mjs'
|
|
7
|
+
export const MAX_MEASUREMENT_MS = 120_000
|
|
8
|
+
|
|
9
|
+
/** Measure committed local history; never clone, fetch, checkout or upload it. */
|
|
10
|
+
export async function measureDurability({ root, log = () => {} }) {
|
|
11
|
+
const notify = message => { try { log(message) } catch {} }
|
|
12
|
+
return new Promise(resolveResult => {
|
|
13
|
+
let worker
|
|
14
|
+
let result = null
|
|
15
|
+
let reason = 'local Git measurement failed'
|
|
16
|
+
let done = false
|
|
17
|
+
const started = Date.now()
|
|
18
|
+
const killOwnedGroup = () => {
|
|
19
|
+
if (!worker?.pid) return
|
|
20
|
+
try {
|
|
21
|
+
if (process.platform !== 'win32') process.kill(-worker.pid, 'SIGKILL')
|
|
22
|
+
else worker.kill('SIGTERM')
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
const finish = value => {
|
|
26
|
+
if (done) return
|
|
27
|
+
done = true
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
process.removeListener('SIGINT', onInterrupt)
|
|
30
|
+
process.removeListener('SIGTERM', onTerminate)
|
|
31
|
+
process.removeListener('exit', killOwnedGroup)
|
|
32
|
+
if (!value) { killOwnedGroup(); notify(`ScaleQuality: Git durability not measured — ${reason}. No Git history was uploaded.`) }
|
|
33
|
+
resolveResult(value)
|
|
34
|
+
}
|
|
35
|
+
const cancel = signal => {
|
|
36
|
+
const hasOtherHandler = process.listenerCount(signal) > 1
|
|
37
|
+
reason = 'measurement was cancelled'
|
|
38
|
+
finish(null)
|
|
39
|
+
// Preserve the caller's signal handling, including Node's default exit.
|
|
40
|
+
if (!hasOtherHandler) process.kill(process.pid, signal)
|
|
41
|
+
}
|
|
42
|
+
const onInterrupt = () => cancel('SIGINT')
|
|
43
|
+
const onTerminate = () => cancel('SIGTERM')
|
|
44
|
+
const timer = setTimeout(() => { reason = 'the 120-second measurement budget was exceeded'; finish(null) }, MAX_MEASUREMENT_MS)
|
|
45
|
+
process.on('SIGINT', onInterrupt)
|
|
46
|
+
process.on('SIGTERM', onTerminate)
|
|
47
|
+
process.on('exit', killOwnedGroup)
|
|
48
|
+
try {
|
|
49
|
+
worker = fork(fileURLToPath(new URL('./durability/worker.mjs', import.meta.url)), [], {
|
|
50
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: localGitEnvironment(),
|
|
51
|
+
execArgv: ['--max-old-space-size=512'], detached: process.platform !== 'win32',
|
|
52
|
+
})
|
|
53
|
+
worker.on('message', message => {
|
|
54
|
+
if (message?.type === 'log' && typeof message.message === 'string' && /^(commits in window: \d+|blame capped: \d+\/\d+ files \(perf\)|blaming \d+ files\.\.\.)$/.test(message.message)) notify(`ScaleQuality: durability — ${message.message}`)
|
|
55
|
+
else if (message?.type === 'result' && validateReport(message.report)) result = message.report
|
|
56
|
+
else if (message?.type === 'failure') reason = typeof message.reason === 'string' ? message.reason : reason
|
|
57
|
+
})
|
|
58
|
+
worker.on('error', () => finish(null))
|
|
59
|
+
worker.on('exit', code => finish(code === 0 && result && validateReport(result) ? result : null))
|
|
60
|
+
worker.send({ root: resolve(root), deadline: started + MAX_MEASUREMENT_MS - 1000 })
|
|
61
|
+
} catch { finish(null) }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const ENGINE_SOURCE_DIGEST = "bc7a01ce93df3aad2f80da281934868ba7327a1a04fad840f35ecc8b422c74cf"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalequality/check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": {
|
|
@@ -26,5 +26,8 @@
|
|
|
26
26
|
"homepage": "https://scalequality.io",
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test test/*.test.mjs"
|
|
29
32
|
}
|
|
30
33
|
}
|