@scalequality/check 0.5.0 → 0.5.2
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 +65 -6
- package/bin/archive.mjs +10 -4
- package/bin/check.mjs +2 -0
- package/bin/composer-vendor.mjs +198 -0
- package/bin/durability/GitReader.mjs +53 -27
- package/bin/durability/PROVENANCE.md +22 -7
- package/bin/durability/provenance.json +6 -2
- package/bin/durability/runtime.mjs +112 -19
- package/bin/durability/worker.mjs +4 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,15 +87,15 @@ source, coverage, and a bounded durability report (at most 1 MiB); it never uplo
|
|
|
87
87
|
the raw `.git` object database. Years of old binary history therefore do not add
|
|
88
88
|
to the archive size. It does not truncate or change your repository.
|
|
89
89
|
|
|
90
|
-
Use a complete checkout for that measurement.
|
|
91
|
-
|
|
90
|
+
Use a complete checkout for that measurement. A shallow or incomplete checkout
|
|
91
|
+
is rejected locally before upload; it is not treated as a full 90-day measurement.
|
|
92
92
|
|
|
93
93
|
```yaml
|
|
94
94
|
# GitHub Actions
|
|
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.2 --project <projectId>
|
|
99
99
|
env:
|
|
100
100
|
SQ_TOKEN: ${{ secrets.SQ_TOKEN }}
|
|
101
101
|
```
|
|
@@ -109,8 +109,8 @@ clone:
|
|
|
109
109
|
|
|
110
110
|
The current-code scanners still run on the server. Historical secret scanning and
|
|
111
111
|
commit-convention analysis are not included in this report; the result shows those
|
|
112
|
-
limits and identifies durability as reported by CI.
|
|
113
|
-
report
|
|
112
|
+
limits and identifies durability as reported by CI. A failed Git collection or
|
|
113
|
+
invalid report stops the CLI before upload, without submitting a replacement score.
|
|
114
114
|
|
|
115
115
|
Version 0.5.0 needs the compatible ScaleQuality server release. The CLI checks the
|
|
116
116
|
server's acknowledgement of this exact report before declaring a passing gate.
|
|
@@ -126,9 +126,68 @@ 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.2 --dry-run
|
|
130
130
|
```
|
|
131
131
|
|
|
132
132
|
The CLI checks compressed size before allocating the upload buffer or sending a
|
|
133
133
|
request. If current source alone cannot fit, it lists the largest included files
|
|
134
134
|
and exits with an actionable error. No oversized upload is sent.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## 0.5.1: Composer dependencies and large Git blame output
|
|
138
|
+
|
|
139
|
+
The CLI recognizes installed Composer dependencies using `composer.json` and a
|
|
140
|
+
valid `composer.lock`. It excludes the default `vendor/`, or a safe relative
|
|
141
|
+
`config.vendor-dir`, while preserving both files for dependency analysis. Nested
|
|
142
|
+
Composer projects are recognized too. A directory named `vendor` alone is not
|
|
143
|
+
sufficient evidence to exclude it. Ambiguous configuration, source paths that
|
|
144
|
+
overlap the dependency directory, and unavailable local package origins keep the
|
|
145
|
+
directory in the archive. The CLI reports which directories it excluded or kept.
|
|
146
|
+
It reads JSON only; it does not run Composer, plugins or scripts.
|
|
147
|
+
|
|
148
|
+
`node_modules/` and the existing generated-output directories remain excluded.
|
|
149
|
+
Coverage reports from application tests are staged separately before packaging.
|
|
150
|
+
Excluding installed dependencies does not certify that their locked versions are
|
|
151
|
+
free of vulnerabilities.
|
|
152
|
+
|
|
153
|
+
Git durability reads incremental blame ranges as a stream instead of buffering
|
|
154
|
+
all per-line metadata and source content. This preserves the native calculation,
|
|
155
|
+
90-day window, 14-day settlement and tracked-file selection. It handles large
|
|
156
|
+
blame output without increasing the worker's memory or time budgets.
|
|
157
|
+
|
|
158
|
+
Archive exclusions, including `--exclude`, do not change Git durability's tracked
|
|
159
|
+
file scope. A tracked dependency can still contribute to the historical metric.
|
|
160
|
+
Changing that methodology requires a separate coordinated version; this patch
|
|
161
|
+
does not silently rewrite prior scores. Coverage may remain absent when the job
|
|
162
|
+
does not generate it; Git collection failures stop the check before upload.
|
|
163
|
+
|
|
164
|
+
Track-only completion now says `measurement completed (track only; no cutoff
|
|
165
|
+
configured)`. Its exit code remains zero; completion does not certify that every
|
|
166
|
+
dimension was measured or that a maturity threshold was passed.
|
|
167
|
+
|
|
168
|
+
The 60 MiB compressed-source limit still applies. A checkout with more than that
|
|
169
|
+
amount of first-party source or ambiguous generated assets is rejected locally;
|
|
170
|
+
this release does not truncate source to force a successful upload.
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
## 0.5.2: complete Git collection before submitting a score
|
|
174
|
+
|
|
175
|
+
The complete HEAD object inventory now streams through the same bounded reader
|
|
176
|
+
as blame. Large old histories no longer have to fit into a 16 MiB output buffer
|
|
177
|
+
for that check. Missing objects remain errors. The 90-day window, 14-day settlement,
|
|
178
|
+
scoring calculations and archive limit are unchanged.
|
|
179
|
+
|
|
180
|
+
Unexpected Git command failures stop collection; they cannot become zero surviving
|
|
181
|
+
lines or an upload with durability omitted. A temporary OS `EAGAIN` failure to
|
|
182
|
+
start Git gets one automatic retry within the existing measurement deadline.
|
|
183
|
+
Timeouts, missing or corrupt history, and resource-limit failures do not trigger
|
|
184
|
+
unbounded retries. A technical failure exits with code 2 and sends no new score.
|
|
185
|
+
|
|
186
|
+
Tracked blob names are preserved exactly, including Git-quoted characters.
|
|
187
|
+
Submodule gitlinks are not source blobs and are not sent to blame; ordinary files,
|
|
188
|
+
symlinks and binary blobs retain their existing measurement treatment.
|
|
189
|
+
|
|
190
|
+
A valid report with zero settled or AI-attributed lines still proceeds. It does
|
|
191
|
+
not invent 100% AI survival. Missing coverage alone does not block submission.
|
|
192
|
+
A completed check below a configured maturity cutoff still exits with code 1;
|
|
193
|
+
track-only completion still exits with code 0.
|
package/bin/archive.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"
|
|
|
2
2
|
import { mkdtempSync, mkdirSync, readdirSync, lstatSync, existsSync, statSync, writeFileSync, rmSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join, resolve, posix } from "node:path"
|
|
5
|
+
import { planComposerExclusions } from "./composer-vendor.mjs"
|
|
5
6
|
|
|
6
7
|
export const ARCHIVE_LIMIT_BYTES = 60 * 1024 * 1024
|
|
7
8
|
const MAX_DURABILITY_REPORT_BYTES = 1024 * 1024
|
|
@@ -23,8 +24,12 @@ export function normalizeExcludes(excludes = []) {
|
|
|
23
24
|
})
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function inventory(root, { reports = false, excludes = [] } = {}) {
|
|
27
|
+
function inventory(root, { reports = false, excludes = [], log = () => {} } = {}) {
|
|
27
28
|
const paths = [], largest = []
|
|
29
|
+
const composerExcluded = reports ? [] : planComposerExclusions(root, excludes, SKIP, log)
|
|
30
|
+
for (const candidate of composerExcluded) {
|
|
31
|
+
log(`ScaleQuality: excluding installed Composer dependencies at ${JSON.stringify(candidate.path)}; retaining ${JSON.stringify(candidate.manifest)} and ${JSON.stringify(candidate.lockfile)} for dependency analysis. Git durability scope is unchanged.`)
|
|
32
|
+
}
|
|
28
33
|
const walk = (rel) => {
|
|
29
34
|
const abs = join(root, rel)
|
|
30
35
|
const st = lstatSync(abs)
|
|
@@ -36,12 +41,12 @@ function inventory(root, { reports = false, excludes = [] } = {}) {
|
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
const visit = (rel, name) => {
|
|
39
|
-
if (!reports && (name === ".git" || rel === ".sqreports" || excludes.some((p) => rel === p || rel.startsWith(`${p}/`)))) return
|
|
44
|
+
if (!reports && (name === ".git" || rel === ".sqreports" || [...excludes, ...composerExcluded.map(item => item.path)].some((p) => rel === p || rel.startsWith(`${p}/`)))) return
|
|
40
45
|
if (!reports && SKIP.has(name) && lstatSync(join(root, rel)).isDirectory()) return
|
|
41
46
|
walk(rel)
|
|
42
47
|
}
|
|
43
48
|
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) }
|
|
49
|
+
return { paths, largest: largest.sort((a, b) => b.bytes - a.bytes).slice(0, 5), composerExcluded }
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -58,7 +63,7 @@ export function prepareCheckoutArchive({ root, coverageRoot, durabilityReport, l
|
|
|
58
63
|
process.once("exit", onExit)
|
|
59
64
|
const cleanup = () => { process.removeListener("exit", onExit); rmSync(temp, { recursive: true, force: true }) }
|
|
60
65
|
try {
|
|
61
|
-
const source = inventory(root, { excludes })
|
|
66
|
+
const source = inventory(root, { excludes, log })
|
|
62
67
|
const sourceList = join(temp, "source.list")
|
|
63
68
|
writeFileSync(sourceList, source.paths.length ? `${source.paths.join("\0")}\0` : "")
|
|
64
69
|
const metadataRoot = join(temp, "metadata")
|
|
@@ -66,6 +71,7 @@ export function prepareCheckoutArchive({ root, coverageRoot, durabilityReport, l
|
|
|
66
71
|
const hasGit = existsSync(join(root, ".git"))
|
|
67
72
|
const history = { mode: hasGit ? "omitted" : "unavailable", reason: durabilityReport ? "ci_report" : hasGit ? "invalid_git" : "missing_git" }
|
|
68
73
|
writeFileSync(join(metadataRoot, ".sqreports/checkout.json"), JSON.stringify({ version: 1, history, archiveLimitBytes: ARCHIVE_LIMIT_BYTES }))
|
|
74
|
+
if (source.composerExcluded.length) writeFileSync(join(metadataRoot, ".sqreports/source.json"), JSON.stringify({ version: 1, composerDependencyExclusions: source.composerExcluded }))
|
|
69
75
|
if (durabilityReport) {
|
|
70
76
|
const json = JSON.stringify(durabilityReport)
|
|
71
77
|
if (Buffer.byteLength(json) > MAX_DURABILITY_REPORT_BYTES) throw new Error("Durability report exceeds the 1 MiB report limit. No upload was sent.")
|
package/bin/check.mjs
CHANGED
|
@@ -216,6 +216,7 @@ async function main() {
|
|
|
216
216
|
const auth = { Authorization: `Bearer ${token}` }
|
|
217
217
|
const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
|
|
218
218
|
const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
|
|
219
|
+
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.")
|
|
219
220
|
const coverage = stageCoverageReports()
|
|
220
221
|
let packed, buf
|
|
221
222
|
try {
|
|
@@ -298,6 +299,7 @@ async function main() {
|
|
|
298
299
|
const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
|
|
299
300
|
log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
|
|
300
301
|
if (last.passed === false) console.error("ScaleQuality: below the line — failing the build.")
|
|
302
|
+
else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
|
|
301
303
|
else log("ScaleQuality: passed.")
|
|
302
304
|
}
|
|
303
305
|
process.exit(last.passed === false ? 1 : 0)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join, posix } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const MAX_JSON_BYTES = 4 * 1024 * 1024
|
|
5
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
6
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) || a === '.' || b === '.'
|
|
7
|
+
|
|
8
|
+
// Inspect data only. Composer plugins/scripts and customer symlinks are never run/followed.
|
|
9
|
+
function readJson(path) {
|
|
10
|
+
let fd
|
|
11
|
+
try {
|
|
12
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
13
|
+
const stat = fstatSync(fd)
|
|
14
|
+
if (!stat.isFile() || stat.size > MAX_JSON_BYTES) return null
|
|
15
|
+
const raw = Buffer.alloc(stat.size + 1)
|
|
16
|
+
let bytes = 0
|
|
17
|
+
while (bytes < raw.length) {
|
|
18
|
+
const count = readSync(fd, raw, bytes, raw.length - bytes, null)
|
|
19
|
+
if (!count) break
|
|
20
|
+
bytes += count
|
|
21
|
+
}
|
|
22
|
+
if (bytes !== stat.size) return null
|
|
23
|
+
const value = JSON.parse(raw.subarray(0, bytes).toString('utf8'))
|
|
24
|
+
return object(value) ? value : null
|
|
25
|
+
} catch { return null }
|
|
26
|
+
finally { if (fd !== undefined) closeSync(fd) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeRelative(value) {
|
|
30
|
+
if (typeof value !== 'string' || !value || /[\\\0*?\[\]$~]/.test(value) || value.startsWith('/') || /^[A-Za-z]:/.test(value)) return null
|
|
31
|
+
if (value.split('/').includes('..')) return null
|
|
32
|
+
const path = posix.normalize(value).replace(/\/$/, '')
|
|
33
|
+
return path === '.' || path.split('/').some(part => ['.git', '.sqreports'].includes(part)) ? null : path
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sourcePath(module, value) {
|
|
37
|
+
if (typeof value !== 'string' || /[\\\0$~]/.test(value) || value.startsWith('/') || /^[A-Za-z]:/.test(value)) return null
|
|
38
|
+
// A glob's fixed directory prefix bounds every possible source it might select.
|
|
39
|
+
const glob = value.search(/[*?\[]/)
|
|
40
|
+
const prefix = glob < 0 ? value : value.slice(0, glob).replace(/[^/]*$/, '')
|
|
41
|
+
const path = posix.normalize(posix.join(module || '.', prefix || '.')).replace(/\/$/, '')
|
|
42
|
+
return path === '..' || path.startsWith('../') ? null : path
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function autoloadPaths(value) {
|
|
46
|
+
if (value === undefined) return []
|
|
47
|
+
if (!object(value)) return [null]
|
|
48
|
+
const paths = []
|
|
49
|
+
for (const key of ['psr-4', 'psr-0', 'classmap', 'files']) {
|
|
50
|
+
const entry = value[key]
|
|
51
|
+
if (entry === undefined) continue
|
|
52
|
+
const values = ['psr-4', 'psr-0'].includes(key) ? object(entry) ? Object.values(entry) : [null] : Array.isArray(entry) ? entry : [null]
|
|
53
|
+
for (const item of values) paths.push(...(Array.isArray(item) ? item : [item]))
|
|
54
|
+
}
|
|
55
|
+
return paths
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function regularDirectory(root, path, consume = () => {}) {
|
|
59
|
+
let current = root
|
|
60
|
+
for (const part of path.split('/')) {
|
|
61
|
+
consume()
|
|
62
|
+
current = join(current, part)
|
|
63
|
+
try { if (!lstatSync(current).isDirectory()) return false }
|
|
64
|
+
catch { return false }
|
|
65
|
+
}
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A transport exclusion, never a change to the native Git durability scope. */
|
|
70
|
+
export function composerVendorDirectory(root, module = '', excludes = [], ignoredDirectories = new Set(), consume = () => {}) {
|
|
71
|
+
const manifest = readJson(join(root, module, 'composer.json'))
|
|
72
|
+
if (!manifest) return null
|
|
73
|
+
const label = posix.join(module, 'composer.json')
|
|
74
|
+
const keep = reason => ({ kept: true, manifest: label, reason })
|
|
75
|
+
const lock = readJson(join(root, module, 'composer.lock'))
|
|
76
|
+
if (!lock || !Array.isArray(lock.packages) || (lock['packages-dev'] !== undefined && !Array.isArray(lock['packages-dev']))) return keep('a valid composer.lock is required')
|
|
77
|
+
const packages = [...lock.packages, ...(lock['packages-dev'] || [])]
|
|
78
|
+
if (!packages.length || packages.some(pkg => !object(pkg) || typeof pkg.name !== 'string' || !pkg.name || typeof pkg.version !== 'string' || !pkg.version)) return keep('locked dependency versions could not be confirmed')
|
|
79
|
+
if (manifest.config !== undefined && !object(manifest.config)) return keep('Composer config is ambiguous')
|
|
80
|
+
const vendor = safeRelative(manifest.config && Object.hasOwn(manifest.config, 'vendor-dir') ? manifest.config['vendor-dir'] : 'vendor')
|
|
81
|
+
if (!vendor) return keep('vendor-dir must be a literal directory within the Composer project')
|
|
82
|
+
const path = posix.join(module, vendor)
|
|
83
|
+
// If either evidence file is explicitly excluded, do not infer a safe replacement for installed dependencies.
|
|
84
|
+
if (['composer.json', 'composer.lock'].some(file => excludes.some(ex => overlaps(ex, posix.join(module, file))))) return keep('the manifest or lockfile was explicitly excluded')
|
|
85
|
+
|
|
86
|
+
const repositories = manifest.repositories === undefined ? [] : Array.isArray(manifest.repositories) ? manifest.repositories : object(manifest.repositories) ? Object.values(manifest.repositories) : [null]
|
|
87
|
+
if (repositories.some(repo => repo !== false && !object(repo))) return keep('Composer repositories are ambiguous')
|
|
88
|
+
const localOrigins = [
|
|
89
|
+
...repositories.filter(repo => repo && repo.type === 'path').map(repo => repo.url),
|
|
90
|
+
...packages.filter(pkg => pkg.dist?.type === 'path').map(pkg => pkg.dist.url),
|
|
91
|
+
]
|
|
92
|
+
// Resolving Composer's glob rules and each symlink target is outside this transport hotfix.
|
|
93
|
+
if (localOrigins.some(value => typeof value !== 'string' || /[*?\[]/.test(value))) return keep('local package origins use ambiguous paths or globs')
|
|
94
|
+
const sources = [...autoloadPaths(manifest.autoload), ...autoloadPaths(manifest['autoload-dev']), ...localOrigins]
|
|
95
|
+
if (sources.some(value => { consume(); const source = sourcePath(module, value); return source === null || overlaps(path, source) })) return keep('local source paths may overlap installed dependencies or lie outside the checkout')
|
|
96
|
+
if (localOrigins.some(value => {
|
|
97
|
+
const source = sourcePath(module, value)
|
|
98
|
+
consume()
|
|
99
|
+
return !regularDirectory(root, source, consume) || source.split('/').some(part => ignoredDirectories.has(part) || part === 'vendor') || excludes.some(ex => overlaps(ex, source))
|
|
100
|
+
})) return keep('a local package origin is missing, linked or excluded from the archive')
|
|
101
|
+
// A custom vendor-dir can have several components: reject a symlink in ANY component.
|
|
102
|
+
if (!regularDirectory(root, path, consume)) return null
|
|
103
|
+
return { path, manifest: label, lockfile: posix.join(module, 'composer.lock') }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function declaredSources(root, module, consume) {
|
|
107
|
+
const manifest = readJson(join(root, module, 'composer.json'))
|
|
108
|
+
if (!manifest) return [null]
|
|
109
|
+
const repositories = manifest.repositories === undefined ? [] : Array.isArray(manifest.repositories) ? manifest.repositories : object(manifest.repositories) ? Object.values(manifest.repositories) : [null]
|
|
110
|
+
if (repositories.some(repo => repo !== false && !object(repo))) return [null]
|
|
111
|
+
const lock = readJson(join(root, module, 'composer.lock'))
|
|
112
|
+
let packages = []
|
|
113
|
+
try {
|
|
114
|
+
lstatSync(join(root, module, 'composer.lock'))
|
|
115
|
+
if (!lock || !Array.isArray(lock.packages) || (lock['packages-dev'] !== undefined && !Array.isArray(lock['packages-dev']))) return [null]
|
|
116
|
+
packages = [...lock.packages, ...(lock['packages-dev'] || [])]
|
|
117
|
+
if (packages.some(pkg => !object(pkg))) return [null]
|
|
118
|
+
} catch (error) { if (error.code !== 'ENOENT') return [null] }
|
|
119
|
+
return [
|
|
120
|
+
...autoloadPaths(manifest.autoload), ...autoloadPaths(manifest['autoload-dev']),
|
|
121
|
+
...repositories.filter(repo => repo && repo.type === 'path').map(repo => repo.url),
|
|
122
|
+
...packages.filter(pkg => pkg.dist?.type === 'path').map(pkg => pkg.dist.url),
|
|
123
|
+
].map(value => {
|
|
124
|
+
consume()
|
|
125
|
+
// Resolving a glob or symlink could reveal another project's only source copy.
|
|
126
|
+
if (typeof value !== 'string' || /[*?\[]/.test(value)) return null
|
|
127
|
+
const path = sourcePath(module, value)
|
|
128
|
+
if (path === null) return null
|
|
129
|
+
let current = root
|
|
130
|
+
for (const part of path.split('/')) {
|
|
131
|
+
consume()
|
|
132
|
+
current = join(current, part)
|
|
133
|
+
try { if (lstatSync(current).isSymbolicLink()) return null }
|
|
134
|
+
catch (error) { if (error.code === 'ENOENT') break; return null }
|
|
135
|
+
}
|
|
136
|
+
return path
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Discover all projects before applying any Composer exclusion. References can cross modules. */
|
|
141
|
+
export function planComposerExclusions(root, excludes = [], ignoredDirectories = new Set(), log = () => {}) {
|
|
142
|
+
const limit = new Error('Composer discovery resource limit')
|
|
143
|
+
const deadline = Date.now() + 5_000
|
|
144
|
+
let work = 0
|
|
145
|
+
const consume = () => { if (++work > 1_000_000 || Date.now() > deadline) throw limit }
|
|
146
|
+
try {
|
|
147
|
+
const projects = []
|
|
148
|
+
const pending = ['']
|
|
149
|
+
let visited = 0
|
|
150
|
+
while (pending.length) {
|
|
151
|
+
consume()
|
|
152
|
+
const module = pending.pop()
|
|
153
|
+
const entries = readdirSync(join(root, module), { withFileTypes: true })
|
|
154
|
+
visited += entries.length
|
|
155
|
+
if (visited > 100_000 || projects.length > 5_000) throw limit
|
|
156
|
+
if (entries.some(entry => entry.name === 'composer.json')) projects.push({
|
|
157
|
+
module,
|
|
158
|
+
candidate: composerVendorDirectory(root, module, excludes, ignoredDirectories, consume),
|
|
159
|
+
sources: declaredSources(root, module, consume),
|
|
160
|
+
})
|
|
161
|
+
for (const entry of entries.sort((a, b) => b.name.localeCompare(a.name))) {
|
|
162
|
+
consume()
|
|
163
|
+
const path = posix.join(module, entry.name)
|
|
164
|
+
if (!entry.isDirectory() || entry.name === '.git' || path === '.sqreports' || ignoredDirectories.has(entry.name) || excludes.some(ex => path === ex || path.startsWith(`${ex}/`))) continue
|
|
165
|
+
pending.push(path)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const candidates = projects.flatMap(project => project.candidate?.path ? [project.candidate] : [])
|
|
169
|
+
const protectedPaths = new Set()
|
|
170
|
+
const isActive = project => !candidates.some(candidate => { consume(); return !protectedPaths.has(candidate.path) && (project.module === candidate.path || project.module.startsWith(`${candidate.path}/`)) })
|
|
171
|
+
// Dependency package manifests do not veto their containing vendor directory.
|
|
172
|
+
// Once an external project references it, it is retained and its declarations
|
|
173
|
+
// become relevant too. Iterate until no newly retained project protects another.
|
|
174
|
+
let changed
|
|
175
|
+
do {
|
|
176
|
+
changed = false
|
|
177
|
+
const sources = projects.filter(isActive).flatMap(project => project.sources)
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
if (!protectedPaths.has(candidate.path) && sources.some(source => { consume(); return source === null || overlaps(candidate.path, source) })) {
|
|
180
|
+
protectedPaths.add(candidate.path)
|
|
181
|
+
changed = true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} while (changed)
|
|
185
|
+
for (const project of projects.filter(isActive)) {
|
|
186
|
+
if (project.candidate?.kept) log(`ScaleQuality: retaining possible Composer dependencies near ${JSON.stringify(project.candidate.manifest)}: ${project.candidate.reason}.`)
|
|
187
|
+
}
|
|
188
|
+
for (const candidate of candidates) {
|
|
189
|
+
if (protectedPaths.has(candidate.path)) log(`ScaleQuality: retaining ${JSON.stringify(candidate.path)}: a project declares source there or its source paths could not be resolved safely.`)
|
|
190
|
+
}
|
|
191
|
+
// Nested installed package manifests cannot create independent upload exclusions.
|
|
192
|
+
return projects.filter(isActive).flatMap(project => project.candidate?.path && !protectedPaths.has(project.candidate.path) ? [project.candidate] : [])
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error !== limit) throw error
|
|
195
|
+
log('ScaleQuality: retaining Composer dependency directories: project discovery exceeded its resource limit.')
|
|
196
|
+
return []
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import { runGit as git,
|
|
1
|
+
import { runGit as git, runGitLines, GitMeasurementError } from "./runtime.mjs";
|
|
2
2
|
const US = "\x1f"; // unit separator (field)
|
|
3
3
|
const RS = "\x1e"; // record separator (commit)
|
|
4
4
|
const CO_AUTHOR_RE = /co-authored-by:\s*(.*?)\s*<([^>]+)>/gi;
|
|
5
|
+
/** `git log --numstat` C-quotes path bytes, independently of the UTF-8 source.
|
|
6
|
+
* Decode only this transport representation; leave rename/statistics semantics
|
|
7
|
+
* unchanged. `ls-tree -z` and blame both use the resulting literal pathname. */
|
|
8
|
+
function decodeGitPath(path) {
|
|
9
|
+
if (!path.startsWith('"') || !path.endsWith('"')) return path;
|
|
10
|
+
const parts = [];
|
|
11
|
+
let literal = '';
|
|
12
|
+
const escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '\\': 92, '"': 34 };
|
|
13
|
+
for (let i = 1; i < path.length - 1; i++) {
|
|
14
|
+
if (path[i] !== '\\') { literal += path[i]; continue; }
|
|
15
|
+
parts.push(Buffer.from(literal)); literal = '';
|
|
16
|
+
const escaped = path[++i];
|
|
17
|
+
if (Object.hasOwn(escapes, escaped)) parts.push(Buffer.from([escapes[escaped]]));
|
|
18
|
+
else if (/^[0-7]{3}$/.test(path.slice(i, i + 3))) {
|
|
19
|
+
parts.push(Buffer.from([parseInt(path.slice(i, i + 3), 8)])); i += 2;
|
|
20
|
+
} else throw new GitMeasurementError('Git file path could not be parsed');
|
|
21
|
+
}
|
|
22
|
+
parts.push(Buffer.from(literal));
|
|
23
|
+
return Buffer.concat(parts).toString('utf8');
|
|
24
|
+
}
|
|
5
25
|
function parseCoAuthors(body) {
|
|
6
26
|
const out = [];
|
|
7
27
|
let m;
|
|
@@ -77,42 +97,48 @@ export async function readCommits(repoPath, sinceDays, untilDays = 0) {
|
|
|
77
97
|
const del = Number.parseInt(cols[1], 10);
|
|
78
98
|
rec.additions += Number.isFinite(add) ? add : 0;
|
|
79
99
|
rec.deletions += Number.isFinite(del) ? del : 0;
|
|
80
|
-
rec.files.push(cols.slice(2).join("\t"));
|
|
100
|
+
rec.files.push(decodeGitPath(cols.slice(2).join("\t")));
|
|
81
101
|
}
|
|
82
102
|
}
|
|
83
103
|
return [...bySha.values()];
|
|
84
104
|
}
|
|
85
|
-
/**
|
|
105
|
+
/** Blameable entries currently at HEAD. Gitlinks point to another repository;
|
|
106
|
+
* native blame already contributed zero for them. Keep all blobs, including
|
|
107
|
+
* symlinks and binaries, without changing the commit/additions denominator. */
|
|
86
108
|
export async function listHeadFiles(repoPath) {
|
|
87
|
-
const out = await git(repoPath, ["ls-tree", "-r", "
|
|
88
|
-
|
|
109
|
+
const out = await git(repoPath, ["ls-tree", "-r", "-z", "HEAD"]);
|
|
110
|
+
const files = new Set();
|
|
111
|
+
for (const line of out.split("\0")) {
|
|
112
|
+
if (!line) continue;
|
|
113
|
+
const entry = /^[0-7]{6} (blob|tree|commit) [0-9a-f]{40}\t([\s\S]+)$/.exec(line);
|
|
114
|
+
if (!entry) throw new GitMeasurementError('Git tree output could not be parsed');
|
|
115
|
+
if (entry[1] === 'blob') files.add(entry[2]);
|
|
116
|
+
}
|
|
117
|
+
return files;
|
|
89
118
|
}
|
|
90
|
-
const
|
|
119
|
+
const BLAME_GROUP = /^([0-9a-f]{40}) [1-9][0-9]* [1-9][0-9]* ([1-9][0-9]*)$/;
|
|
91
120
|
/**
|
|
92
121
|
* Blames a file at HEAD and returns, per introducing commit sha, how many
|
|
93
|
-
* CURRENT lines it owns
|
|
94
|
-
*
|
|
95
|
-
*
|
|
122
|
+
* CURRENT lines it owns. Incremental output describes identical attribution
|
|
123
|
+
* in groups, without source lines or repeated per-line commit metadata.
|
|
124
|
+
* Stream those groups so large output does not require a large buffer.
|
|
125
|
+
* Callers select files present at HEAD. A failed command is incomplete evidence,
|
|
126
|
+
* never proof that this file has no surviving lines.
|
|
96
127
|
*/
|
|
97
128
|
export async function blameFile(repoPath, file) {
|
|
98
129
|
const counts = new Map();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
if (isFatalGitError(error)) throw error;
|
|
115
|
-
// deleted at HEAD / binary / unreadable → no surviving lines counted
|
|
116
|
-
}
|
|
130
|
+
await runGitLines(repoPath, [
|
|
131
|
+
"blame",
|
|
132
|
+
"--incremental",
|
|
133
|
+
"HEAD",
|
|
134
|
+
"--",
|
|
135
|
+
file,
|
|
136
|
+
], line => {
|
|
137
|
+
const m = BLAME_GROUP.exec(line);
|
|
138
|
+
if (!m) return;
|
|
139
|
+
const total = (counts.get(m[1]) ?? 0) + Number(m[2]);
|
|
140
|
+
if (!Number.isSafeInteger(total)) throw new GitMeasurementError('Git blame line count exceeded the numeric limit');
|
|
141
|
+
counts.set(m[1], total);
|
|
142
|
+
});
|
|
117
143
|
return counts;
|
|
118
144
|
}
|
|
@@ -12,9 +12,18 @@ moduleResolution node, strict true, esModuleInterop true, skipLibCheck true, and
|
|
|
12
12
|
Node type declarations. No dependencies are needed by the resulting runtime.
|
|
13
13
|
Rename the generated files to `.mjs` and give their relative imports `.mjs`
|
|
14
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
|
-
|
|
17
|
-
|
|
15
|
+
reader, replace its promisified `execFile` wrapper with `runtime.mjs`'s `runGit`.
|
|
16
|
+
For blame, use `runGitLines` with `--incremental` and sum each group's line count
|
|
17
|
+
instead of counting individual `--line-porcelain` headers. Attribution is unchanged;
|
|
18
|
+
source text and repeated per-line metadata are not emitted or retained. Any failed
|
|
19
|
+
blame aborts the report instead of returning empty or partial counts. Read the
|
|
20
|
+
object type from `ls-tree -rz` and select only blobs, preserving literal names
|
|
21
|
+
without trimming. Decode Git's C-quoted path bytes in `log --numstat` so tabs,
|
|
22
|
+
quotes, Unicode and leading/trailing whitespace match these same blob names.
|
|
23
|
+
This skips only submodule gitlinks,
|
|
24
|
+
whose native blame contributed zero; binary and symlink blobs remain eligible.
|
|
25
|
+
Commit statistics and the introduced-lines denominator remain unchanged.
|
|
26
|
+
Recompute provenance hashes and run the
|
|
18
27
|
parity and cancellation tests. The reference modules under
|
|
19
28
|
`test/native-durability-reference/` preserve the original reader for small fixture
|
|
20
29
|
comparisons only; they are excluded from the published package.
|
|
@@ -27,10 +36,16 @@ part of native Git measurement even if archive exclusions omit those directories
|
|
|
27
36
|
No clone, fetch, checkout or Git write is performed. Shallow or partial histories,
|
|
28
37
|
missing objects, changing HEAD and invalid reports produce no measurement.
|
|
29
38
|
The separate worker has a 120-second total budget and 512 MiB V8 heap ceiling;
|
|
30
|
-
each Git command has at most 30 seconds
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
each Git command has at most 30 seconds. Buffered commands retain at most 16 MiB
|
|
40
|
+
of output. Incremental blame and the complete HEAD object inventory stream stdout,
|
|
41
|
+
retaining one line (at most 16 MiB) and at most 16 MiB of stderr, rather than
|
|
42
|
+
limiting total streamed output. One failed OS spawn with `EAGAIN` can recover after
|
|
43
|
+
100 ms, within the original total deadline; the recovery allowance is shared by
|
|
44
|
+
all Git commands. Command exits, signals, timeouts, missing/incomplete history and
|
|
45
|
+
deterministic resource failures are never automatically retried.
|
|
46
|
+
Collection failures abort the CLI before packaging or upload and cannot submit a
|
|
47
|
+
replacement score. A validated report with zero settled or AI-eligible lines is
|
|
48
|
+
still a successful collection; an absent optional coverage report is permitted.
|
|
34
49
|
Only the measurement worker's own process group and Git children are cleaned up
|
|
35
50
|
on cancellation or parent IPC disconnection. Git command output, author details,
|
|
36
51
|
credentials and customer environment tokens are not logged or sent to the worker.
|
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
"runtimeChanges": [
|
|
30
30
|
"ESM relative import extensions only in engine/classifier",
|
|
31
31
|
"GitReader process wrapper replaced by bounded local-only runner",
|
|
32
|
-
"GitReader blame
|
|
32
|
+
"GitReader blame aborts the report on any failed command; partial counts never become a successful measurement",
|
|
33
|
+
"GitReader selects literal blob paths from NUL-delimited ls-tree and decodes C-quoted numstat filenames; submodule gitlinks retain zero native contribution without changing introduced-line counts",
|
|
34
|
+
"GitReader blame streams incremental groups and sums their line counts with exact native attribution; bounded lines and stderr preserve resource limits without capping total streamed stdout",
|
|
35
|
+
"Worker streams the complete HEAD object inventory and rejects missing objects without buffering total output",
|
|
36
|
+
"Runtime permits one recovery from OS spawn EAGAIN within the original deadline; all other collection failures abort the CLI before upload"
|
|
33
37
|
],
|
|
34
38
|
"generatedFiles": [
|
|
35
39
|
{
|
|
@@ -38,7 +42,7 @@
|
|
|
38
42
|
},
|
|
39
43
|
{
|
|
40
44
|
"file": "GitReader.mjs",
|
|
41
|
-
"sha256": "
|
|
45
|
+
"sha256": "e86e2e8251f5d9e8f50f671805d8926346aeb574bd2d6a115e5cd324555858f3"
|
|
42
46
|
},
|
|
43
47
|
{
|
|
44
48
|
"file": "OriginClassifier.mjs",
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process'
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
2
3
|
|
|
3
4
|
export const MAX_GIT_BUFFER = 16 * 1024 * 1024
|
|
4
5
|
export const MAX_GIT_TIME_MS = 30_000
|
|
5
6
|
let deadline = 0
|
|
7
|
+
let spawnRecoveryUsed = false
|
|
6
8
|
const children = new Set()
|
|
7
9
|
|
|
8
10
|
export class GitMeasurementError extends Error {
|
|
9
11
|
constructor(reason) { super(reason); this.reason = reason; this.fatalMeasurement = true }
|
|
10
12
|
}
|
|
11
13
|
export const isFatalGitError = (error) => error?.fatalMeasurement === true
|
|
12
|
-
export function setDeadline(value) { deadline = value }
|
|
14
|
+
export function setDeadline(value) { deadline = value; spawnRecoveryUsed = false }
|
|
13
15
|
export function remainingTime() {
|
|
14
16
|
const remaining = deadline - Date.now()
|
|
15
17
|
if (remaining <= 0) throw new GitMeasurementError('time budget exceeded')
|
|
@@ -27,29 +29,120 @@ export function localGitEnvironment() {
|
|
|
27
29
|
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
30
|
}
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
function gitArgs(repoPath, args) {
|
|
33
|
+
return [
|
|
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
|
+
]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function gitError(error, stderr) {
|
|
41
|
+
// EAGAIN is a failed OS spawn, before Git ran or emitted any result. A child
|
|
42
|
+
// exit status, timeout or deterministic Git/resource error is not retryable.
|
|
43
|
+
if (error.code === 'EAGAIN') {
|
|
44
|
+
const failure = new GitMeasurementError('Git process could not start because system resources are temporarily unavailable')
|
|
45
|
+
failure.retryableSpawn = true
|
|
46
|
+
return failure
|
|
47
|
+
}
|
|
48
|
+
if (error.code === 'ENOENT') return new GitMeasurementError('Git is unavailable')
|
|
49
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS') return new GitMeasurementError('Git output exceeded the 16 MiB resource limit')
|
|
50
|
+
if (error.killed || error.signal || error.code === 'ETIMEDOUT') return new GitMeasurementError('Git command exceeded the time budget')
|
|
51
|
+
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 || '')) {
|
|
52
|
+
return new GitMeasurementError('Git history is incomplete or unavailable locally')
|
|
53
|
+
}
|
|
54
|
+
// Never propagate command output, author identities or credentials to logs.
|
|
55
|
+
const safeError = new GitMeasurementError('Git command failed')
|
|
56
|
+
safeError.code = error.code
|
|
57
|
+
return safeError
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function withSpawnRecovery(operation) {
|
|
61
|
+
try { return await operation() } catch (error) {
|
|
62
|
+
if (!error?.retryableSpawn || spawnRecoveryUsed) throw error
|
|
63
|
+
spawnRecoveryUsed = true
|
|
64
|
+
remainingTime()
|
|
65
|
+
await delay(Math.min(100, remainingTime()))
|
|
66
|
+
remainingTime()
|
|
67
|
+
return operation()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runGit(repoPath, args, options = {}) {
|
|
72
|
+
return withSpawnRecovery(() => runGitOnce(repoPath, args, options))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runGitOnce(repoPath, args, { allowMissingConfig = false } = {}) {
|
|
31
76
|
const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
|
|
32
77
|
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) => {
|
|
78
|
+
const child = execFile('git', gitArgs(repoPath, args), { encoding: 'utf8', maxBuffer: MAX_GIT_BUFFER, timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment() }, (error, stdout, stderr) => {
|
|
38
79
|
children.delete(child)
|
|
39
80
|
if (!error) { try { remainingTime(); resolve(stdout) } catch (e) { reject(e) }; return }
|
|
40
81
|
if (allowMissingConfig && error.code === 1) { resolve(''); return }
|
|
41
|
-
|
|
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)
|
|
82
|
+
reject(gitError(error, stderr))
|
|
52
83
|
})
|
|
53
84
|
children.add(child)
|
|
54
85
|
})
|
|
55
86
|
}
|
|
87
|
+
|
|
88
|
+
/** Consume stdout a line at a time; retain at most one bounded line, not the full output. */
|
|
89
|
+
export async function runGitLines(repoPath, args, onLine) {
|
|
90
|
+
return withSpawnRecovery(() => runGitLinesOnce(repoPath, args, onLine))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runGitLinesOnce(repoPath, args, onLine) {
|
|
94
|
+
const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
let pending = ''
|
|
97
|
+
let stderr = ''
|
|
98
|
+
let failure
|
|
99
|
+
const child = spawn('git', gitArgs(repoPath, args), {
|
|
100
|
+
stdio: ['ignore', 'pipe', 'pipe'], timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment(),
|
|
101
|
+
})
|
|
102
|
+
children.add(child)
|
|
103
|
+
const fail = error => {
|
|
104
|
+
failure ??= error
|
|
105
|
+
child.kill('SIGKILL')
|
|
106
|
+
}
|
|
107
|
+
const emit = line => {
|
|
108
|
+
if (Buffer.byteLength(line) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
|
|
109
|
+
onLine(line)
|
|
110
|
+
}
|
|
111
|
+
child.stdout.setEncoding('utf8')
|
|
112
|
+
child.stdout.on('data', chunk => {
|
|
113
|
+
if (failure) return
|
|
114
|
+
try {
|
|
115
|
+
remainingTime()
|
|
116
|
+
const text = pending + chunk
|
|
117
|
+
let start = 0
|
|
118
|
+
for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
|
|
119
|
+
emit(text.slice(start, end))
|
|
120
|
+
start = end + 1
|
|
121
|
+
}
|
|
122
|
+
pending = text.slice(start)
|
|
123
|
+
if (Buffer.byteLength(pending) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
|
|
124
|
+
} catch (error) {
|
|
125
|
+
fail(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
child.stderr.setEncoding('utf8')
|
|
129
|
+
child.stderr.on('data', chunk => {
|
|
130
|
+
if (failure) return
|
|
131
|
+
if (Buffer.byteLength(stderr) + Buffer.byteLength(chunk) > MAX_GIT_BUFFER) fail(gitError({ code: 'ENOBUFS' }))
|
|
132
|
+
else stderr += chunk
|
|
133
|
+
})
|
|
134
|
+
child.on('error', error => { failure ??= gitError(error, stderr) })
|
|
135
|
+
child.on('close', (code, signal) => {
|
|
136
|
+
children.delete(child)
|
|
137
|
+
if (failure) { reject(failure); return }
|
|
138
|
+
if (code !== 0 || signal) { reject(gitError({ code, signal, killed: child.killed }, stderr)); return }
|
|
139
|
+
try {
|
|
140
|
+
remainingTime()
|
|
141
|
+
if (pending) emit(pending)
|
|
142
|
+
resolve()
|
|
143
|
+
} catch (error) {
|
|
144
|
+
reject(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpath } from 'node:fs/promises'
|
|
2
2
|
import { analyzeDurability } from './GitDurabilityEngine.mjs'
|
|
3
3
|
import { ENGINE_COMMIT, validateReport } from './validate.mjs'
|
|
4
|
-
import { runGit, setDeadline, remainingTime, stopGitChildren, GitMeasurementError } from './runtime.mjs'
|
|
4
|
+
import { runGit, runGitLines, setDeadline, remainingTime, stopGitChildren, GitMeasurementError } from './runtime.mjs'
|
|
5
5
|
|
|
6
6
|
process.on('SIGTERM', () => { stopGitChildren(); process.exit(2) })
|
|
7
7
|
let completed = false
|
|
@@ -20,8 +20,9 @@ process.once('message', async ({ root, deadline }) => {
|
|
|
20
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
21
|
const headSha = (await runGit(root, ['rev-parse', '--verify', 'HEAD'])).trim()
|
|
22
22
|
if (!/^[0-9a-f]{40}$/.test(headSha)) throw new GitMeasurementError('a valid SHA-1 Git HEAD is required')
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
await runGitLines(root, ['rev-list', '--objects', '--no-object-names', '--missing=print', 'HEAD'], line => {
|
|
24
|
+
if (/^\?[0-9a-f]+$/.test(line)) throw new GitMeasurementError('Git history is missing local objects')
|
|
25
|
+
})
|
|
25
26
|
const measuredAt = new Date().toISOString()
|
|
26
27
|
const result = await analyzeDurability(root, {
|
|
27
28
|
windowDays: 90, settleDays: 14,
|
package/package.json
CHANGED