@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.14
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/CHANGELOG.md +233 -0
- package/README.md +22 -5
- package/docs/blocks/capability-stewardship.md +1 -1
- package/docs/blocks/learning-harnesses.md +1 -1
- package/docs/capability-stewardship.md +2 -4
- package/docs/ingestion.md +3 -3
- package/docs/inquiry-harnesses.md +2 -3
- package/docs/install.md +3 -3
- package/docs/learning-harnesses.md +2 -3
- package/docs/local-services.md +42 -3
- package/docs/obsidian-contract.md +23 -0
- package/docs/obsidian-plugin.md +214 -3
- package/docs/obsidian.md +271 -11
- package/docs/personal-workspace.md +13 -4
- package/docs/release-engineering.md +2 -1
- package/docs/repo-boundary-guard.md +26 -3
- package/docs/semantic-operation.md +4 -3
- package/docs/upgrade.md +174 -2
- package/fixtures/templates/local-library.v1.json +2 -1
- package/package.json +2 -1
- package/plugins/obsidian/main.js +57 -8
- package/plugins/obsidian/manifest.json +1 -1
- package/src/boundary/content-rules.mjs +532 -67
- package/src/boundary/policy.mjs +135 -56
- package/src/cli/run.mjs +3 -2
- package/src/commands/obsidian.mjs +23 -7
- package/src/graph/graph.mjs +13 -5
- package/src/graph/knowledge-graph.mjs +19 -1
- package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
- package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
- package/src/ingestion/optional/graphify/native-pins.json +306 -0
- package/src/knowledge-health/rule-identity.mjs +14 -9
- package/src/personal-workspace/index.mjs +1 -1
- package/src/personal-workspace/selection.mjs +17 -1
- package/src/project/path-match.mjs +2 -1
- package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
- package/src/projection/obsidian/personal-workspace.mjs +87 -4
- package/src/projection/obsidian/proposals/adapter.mjs +46 -23
- package/src/projection/obsidian/proposals/observation.mjs +40 -2
- package/src/projection/obsidian/publication/publisher.mjs +72 -5
- package/src/projection/project.mjs +3 -2
- package/src/runtime/obsidian/engine.mjs +293 -55
- package/src/runtime/obsidian/lifecycle.mjs +17 -12
- package/src/runtime/obsidian/login-item.mjs +4 -1
- package/src/runtime/obsidian/observation.mjs +36 -4
- package/src/runtime/obsidian/opening.mjs +3 -2
- package/src/runtime/obsidian/personal-selection.mjs +125 -0
- package/src/runtime/obsidian/pipeline.mjs +44 -3
- package/src/runtime/obsidian/private-lock.mjs +39 -4
- package/src/runtime/obsidian/service-main.mjs +58 -12
- package/src/runtime/obsidian/service.mjs +66 -6
- package/src/runtime/obsidian/view-permission.mjs +334 -0
- package/src/ui/control-system.mjs +201 -0
- package/src/ui/html-primitives.mjs +3 -0
- package/src/upgrade/closure-diagnostics.mjs +135 -0
- package/src/upgrade/closure.mjs +649 -0
- package/src/upgrade/upgrade.mjs +19 -7
- package/templates/external-project-workspace/pack-lifecycle.json +2 -1
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import fs from 'node:fs'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { matchesPathPattern, normalizeRelPath } from '../project/path-match.mjs'
|
|
5
6
|
import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
|
|
6
7
|
|
|
7
8
|
export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
|
9
|
+
const EMPTY_TREES = Object.freeze({ sha1: EMPTY_TREE, sha256: '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321' })
|
|
10
|
+
// Commits one push may carry before the per-commit scan refuses rather than run long.
|
|
11
|
+
export const PUSH_COMMIT_SCAN_MAX = 1000
|
|
8
12
|
export const ZERO_SHA_RE = /^0{40,}$/
|
|
9
13
|
|
|
10
14
|
export const RULE_KINDS = Object.freeze(['content', 'path'])
|
|
@@ -128,6 +132,9 @@ export function resolveContentRules(policy) {
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
export function findException({ exceptions = [], rule, repo, path: filePath }) {
|
|
135
|
+
// Exception paths are slash-normalised patterns, so they cannot name a file whose
|
|
136
|
+
// own name holds a backslash; such a file never inherits a sibling's exception.
|
|
137
|
+
if (String(filePath ?? '').includes('\\')) return null
|
|
131
138
|
const rel = normalizeRelPath(filePath)
|
|
132
139
|
return (
|
|
133
140
|
exceptions.find(
|
|
@@ -141,6 +148,8 @@ export function findException({ exceptions = [], rule, repo, path: filePath }) {
|
|
|
141
148
|
|
|
142
149
|
function rulePathMatches(rule, filePath) {
|
|
143
150
|
if (!Array.isArray(rule.paths) || !rule.paths.length) return true
|
|
151
|
+
// Path scopes are slash-normalised too; a backslash name is treated as in scope.
|
|
152
|
+
if (String(filePath ?? '').includes('\\')) return true
|
|
144
153
|
return rule.paths.some((pattern) => matchesPathPattern(pattern, filePath))
|
|
145
154
|
}
|
|
146
155
|
|
|
@@ -154,13 +163,15 @@ function rulePathMatches(rule, filePath) {
|
|
|
154
163
|
export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, exceptions = [], repo = null }) {
|
|
155
164
|
const findings = []
|
|
156
165
|
for (const file of files) {
|
|
157
|
-
|
|
166
|
+
// Paths from Git are exact repository paths and are reported as they are.
|
|
167
|
+
const filePath = String(file.path ?? '')
|
|
158
168
|
for (const rule of rules) {
|
|
159
169
|
const kind = rule.kind ?? 'content'
|
|
160
170
|
const severity = rule.severity ?? 'error'
|
|
161
171
|
const exception = findException({ exceptions, rule: rule.id, repo, path: filePath })
|
|
162
172
|
if (kind === 'path') {
|
|
163
|
-
|
|
173
|
+
const pathRule = new RegExp(rule.pattern, 'i')
|
|
174
|
+
if (!file.added || !(pathRule.test(filePath) || pathRule.test(normalizeRelPath(filePath)))) continue
|
|
164
175
|
if (exception) continue
|
|
165
176
|
findings.push({ severity, code: 'content-rule-violation', rule: rule.id, repo, path: filePath, line: null, message: `${repo ?? ''}/${filePath}: ${rule.description ?? rule.id}`.replace(/^\//, '') })
|
|
166
177
|
continue
|
|
@@ -170,7 +181,7 @@ export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, excepti
|
|
|
170
181
|
for (const line of file.addedLines ?? []) {
|
|
171
182
|
if (!re.test(line.text)) continue
|
|
172
183
|
if (exception) continue
|
|
173
|
-
|
|
184
|
+
const found = {
|
|
174
185
|
severity,
|
|
175
186
|
code: 'content-rule-violation',
|
|
176
187
|
rule: rule.id,
|
|
@@ -178,45 +189,242 @@ export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, excepti
|
|
|
178
189
|
path: filePath,
|
|
179
190
|
line: line.number,
|
|
180
191
|
message: `${repo ?? ''}/${filePath}:${line.number}: ${rule.description ?? rule.id}`.replace(/^\//, ''),
|
|
181
|
-
}
|
|
192
|
+
}
|
|
193
|
+
// A digest of the line identifies the same content across diffs; it is
|
|
194
|
+
// not enumerable, so it never appears in a report.
|
|
195
|
+
Object.defineProperty(found, 'lineDigest', { value: createHash('sha256').update(line.text).digest('hex') })
|
|
196
|
+
findings.push(found)
|
|
182
197
|
}
|
|
183
198
|
}
|
|
184
199
|
}
|
|
185
200
|
return findings
|
|
186
201
|
}
|
|
187
202
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
// The diff commands below pin their own output format, so repository or user
|
|
204
|
+
// configuration (prefixes, colour, external diff drivers, textconv, relative
|
|
205
|
+
// paths, fused hunks, submodule summaries) cannot change what the parser reads.
|
|
206
|
+
// Anything it still cannot read is reported and blocks, so a key not pinned here
|
|
207
|
+
// can stop a commit but cannot hide content.
|
|
208
|
+
export const CANONICAL_DIFF_FLAGS = Object.freeze([
|
|
209
|
+
'--no-ext-diff',
|
|
210
|
+
'--no-textconv',
|
|
211
|
+
'--no-color',
|
|
212
|
+
'--src-prefix=a/',
|
|
213
|
+
'--dst-prefix=b/',
|
|
214
|
+
'--no-relative',
|
|
215
|
+
'--inter-hunk-context=0',
|
|
216
|
+
'--submodule=short',
|
|
217
|
+
'--full-index',
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
const OCTAL_ESCAPE = /^[0-7]{3}$/
|
|
221
|
+
const SIMPLE_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Read one C-quoted path token as Git writes it (core.quotePath), starting at the
|
|
225
|
+
* opening quote. Returns the decoded path and the index after the closing quote,
|
|
226
|
+
* or null when the token is malformed.
|
|
227
|
+
*/
|
|
228
|
+
function readQuotedToken(text, start) {
|
|
229
|
+
if (text[start] !== '"') return null
|
|
230
|
+
const bytes = []
|
|
231
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
232
|
+
const char = String.fromCodePoint(text.codePointAt(index))
|
|
233
|
+
if (char === '"') return { value: Buffer.from(bytes).toString('utf8'), end: index + 1 }
|
|
234
|
+
if (char !== '\\') {
|
|
235
|
+
bytes.push(...Buffer.from(char, 'utf8'))
|
|
236
|
+
index += char.length - 1
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
const next = text[index + 1]
|
|
240
|
+
if (next === undefined) return null
|
|
241
|
+
const octal = text.slice(index + 1, index + 4)
|
|
242
|
+
if (OCTAL_ESCAPE.test(octal)) {
|
|
243
|
+
const byte = Number.parseInt(octal, 8)
|
|
244
|
+
if (byte > 255) return null
|
|
245
|
+
bytes.push(byte)
|
|
246
|
+
index += 3
|
|
247
|
+
} else if (Object.hasOwn(SIMPLE_ESCAPES, next)) {
|
|
248
|
+
bytes.push(SIMPLE_ESCAPES[next])
|
|
249
|
+
index += 1
|
|
250
|
+
} else {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A path as it appears after a fixed prefix in a header line, quoted or bare. */
|
|
258
|
+
function prefixedPath(token, prefix) {
|
|
259
|
+
if (token.startsWith('"')) {
|
|
260
|
+
const quoted = readQuotedToken(token, 0)
|
|
261
|
+
if (!quoted || quoted.end !== token.length || !quoted.value.startsWith(prefix)) return null
|
|
262
|
+
return quoted.value.slice(prefix.length)
|
|
263
|
+
}
|
|
264
|
+
return token.startsWith(prefix) ? token.slice(prefix.length) : null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The destination path of a `diff --git` header. A bare header is ambiguous when
|
|
269
|
+
* a path contains " b/", so it is resolved only when both sides name the same
|
|
270
|
+
* path, which is every case except a rename or copy; those are resolved from the
|
|
271
|
+
* `rename to` / `copy to` lines that follow.
|
|
272
|
+
*/
|
|
273
|
+
export function diffHeaderPath(line) {
|
|
274
|
+
if (!line.startsWith('diff --git ')) return null
|
|
275
|
+
const rest = line.slice('diff --git '.length)
|
|
276
|
+
if (rest.startsWith('"')) {
|
|
277
|
+
const first = readQuotedToken(rest, 0)
|
|
278
|
+
if (!first || rest[first.end] !== ' ') return null
|
|
279
|
+
return prefixedPath(rest.slice(first.end + 1), 'b/')
|
|
280
|
+
}
|
|
281
|
+
if (rest.endsWith('"')) {
|
|
282
|
+
const open = rest.lastIndexOf(' "')
|
|
283
|
+
return open < 0 ? null : prefixedPath(rest.slice(open + 1), 'b/')
|
|
284
|
+
}
|
|
285
|
+
if (rest.length < 5 || (rest.length - 5) % 2 !== 0) return null
|
|
286
|
+
const half = (rest.length - 5) / 2
|
|
287
|
+
const candidate = rest.slice(2, 2 + half)
|
|
288
|
+
return half > 0 && rest === `a/${candidate} b/${candidate}` ? candidate : null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
292
|
+
const INDEX_LINE = /^index ([0-9a-f]{40,64})\.\.([0-9a-f]{40,64})(?: \d+)?$/
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Split `git diff --unified=0` output into file sections. Lines are read as
|
|
296
|
+
* content only inside a hunk, for exactly as many lines as its header counts, so
|
|
297
|
+
* content can never be read as a header and a header can never hide content. A
|
|
298
|
+
* section whose path or hunks cannot be read is returned with `incomplete` set,
|
|
299
|
+
* so the caller fails closed instead of skipping it. Paths are the exact
|
|
300
|
+
* repository paths Git reports; `newBlob` is the object id of the new content.
|
|
301
|
+
* With `detail`, each section also keeps its hunks (added and removed text) and
|
|
302
|
+
* the raw lines after its header.
|
|
303
|
+
*/
|
|
304
|
+
export function parseDiffSections(diff, { detail = false } = {}) {
|
|
305
|
+
const sections = []
|
|
191
306
|
let current = null
|
|
307
|
+
let pendingAdded = 0
|
|
308
|
+
let pendingRemoved = 0
|
|
192
309
|
let lineNumber = 0
|
|
193
310
|
for (const raw of String(diff ?? '').split('\n')) {
|
|
311
|
+
if (detail && current && !raw.startsWith('diff --git ') && !raw.startsWith('* Unmerged path ')) current.lines.push(raw)
|
|
312
|
+
if (pendingAdded > 0 || pendingRemoved > 0) {
|
|
313
|
+
if (raw.startsWith('+') && pendingAdded > 0) {
|
|
314
|
+
current.addedLines.push({ number: lineNumber, text: raw.slice(1) })
|
|
315
|
+
if (detail) current.hunks.at(-1).added.push(raw.slice(1))
|
|
316
|
+
lineNumber += 1
|
|
317
|
+
pendingAdded -= 1
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
if (raw.startsWith('-') && pendingRemoved > 0) {
|
|
321
|
+
if (detail) current.hunks.at(-1).removed.push(raw.slice(1))
|
|
322
|
+
pendingRemoved -= 1
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
if (raw.startsWith(' ') && pendingAdded > 0 && pendingRemoved > 0) {
|
|
326
|
+
// A context line counts on both sides; none is expected, but one is harmless.
|
|
327
|
+
lineNumber += 1
|
|
328
|
+
pendingAdded -= 1
|
|
329
|
+
pendingRemoved -= 1
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
if (raw.startsWith('\\')) continue // ""
|
|
333
|
+
current.incomplete = 'hunk-shorter-than-its-header'
|
|
334
|
+
pendingAdded = 0
|
|
335
|
+
pendingRemoved = 0
|
|
336
|
+
}
|
|
194
337
|
if (raw.startsWith('diff --git ')) {
|
|
195
|
-
const
|
|
196
|
-
current = {
|
|
197
|
-
|
|
338
|
+
const headerPath = diffHeaderPath(raw)
|
|
339
|
+
current = {
|
|
340
|
+
path: headerPath === null || headerPath === '' ? null : headerPath,
|
|
341
|
+
added: false,
|
|
342
|
+
deleted: false,
|
|
343
|
+
binary: false,
|
|
344
|
+
newBlob: null,
|
|
345
|
+
oldPath: null,
|
|
346
|
+
addedLines: [],
|
|
347
|
+
incomplete: null,
|
|
348
|
+
inHeader: true,
|
|
349
|
+
...(detail ? { hunks: [], lines: [] } : {}),
|
|
350
|
+
}
|
|
351
|
+
sections.push(current)
|
|
198
352
|
continue
|
|
199
353
|
}
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
354
|
+
if (raw.startsWith('* Unmerged path ')) {
|
|
355
|
+
// An unmerged entry has no patch to read; it stands as its own unreadable
|
|
356
|
+
// section instead of being taken for a line of the file before it. Git
|
|
357
|
+
// prints this name as it is, never quoted.
|
|
358
|
+
const listed = raw.slice('* Unmerged path '.length)
|
|
359
|
+
current = {
|
|
360
|
+
path: listed !== '' ? listed : null,
|
|
361
|
+
added: false,
|
|
362
|
+
deleted: false,
|
|
363
|
+
binary: false,
|
|
364
|
+
newBlob: null,
|
|
365
|
+
oldPath: null,
|
|
366
|
+
addedLines: [],
|
|
367
|
+
incomplete: 'unmerged-path',
|
|
368
|
+
inHeader: false,
|
|
369
|
+
...(detail ? { hunks: [], lines: [] } : {}),
|
|
370
|
+
}
|
|
371
|
+
sections.push(current)
|
|
203
372
|
continue
|
|
204
373
|
}
|
|
205
|
-
|
|
374
|
+
if (!current) continue
|
|
375
|
+
if (raw.startsWith('\\')) continue
|
|
376
|
+
const hunk = raw.match(HUNK_HEADER)
|
|
206
377
|
if (hunk) {
|
|
207
|
-
|
|
378
|
+
current.inHeader = false
|
|
379
|
+
if (detail) current.hunks.push({ added: [], removed: [] })
|
|
380
|
+
pendingRemoved = hunk[1] === undefined ? 1 : Number(hunk[1])
|
|
381
|
+
lineNumber = Number(hunk[2])
|
|
382
|
+
pendingAdded = hunk[3] === undefined ? 1 : Number(hunk[3])
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
if (raw.startsWith('Binary files ') && raw.endsWith(' differ')) {
|
|
386
|
+
current.inHeader = false
|
|
387
|
+
current.binary = true
|
|
208
388
|
continue
|
|
209
389
|
}
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
390
|
+
if (!current.inHeader) {
|
|
391
|
+
if (raw !== '') current.incomplete ??= 'unexpected-line-outside-a-hunk'
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
const index = raw.match(INDEX_LINE)
|
|
395
|
+
if (index) {
|
|
396
|
+
current.newBlob = ZERO_SHA_RE.test(index[2]) ? null : index[2]
|
|
397
|
+
} else if (raw.startsWith('new file mode ')) current.added = true
|
|
398
|
+
else if (raw.startsWith('deleted file mode ')) current.deleted = true
|
|
399
|
+
else if (raw.startsWith('rename from ') || raw.startsWith('copy from ')) {
|
|
400
|
+
const source = raw.slice(raw.indexOf(' from ') + 6)
|
|
401
|
+
const named = source.startsWith('"') ? readQuotedToken(source, 0) : { value: source, end: source.length }
|
|
402
|
+
if (named && named.end === source.length && named.value !== '') current.oldPath = named.value
|
|
403
|
+
else current.incomplete ??= 'unreadable-rename-source'
|
|
404
|
+
} else if (raw.startsWith('rename to ') || raw.startsWith('copy to ')) {
|
|
405
|
+
const target = raw.slice(raw.indexOf(' to ') + 4)
|
|
406
|
+
const named = target.startsWith('"') ? readQuotedToken(target, 0) : { value: target, end: target.length }
|
|
407
|
+
if (named && named.end === target.length && named.value !== '') current.path = named.value
|
|
408
|
+
else current.incomplete ??= 'unreadable-rename-target'
|
|
409
|
+
} else if (raw.startsWith('+++ ')) {
|
|
410
|
+
const target = raw.slice(4)
|
|
411
|
+
if (target !== '/dev/null' && current.path === null) {
|
|
412
|
+
const named = prefixedPath(target, 'b/')
|
|
413
|
+
if (named !== null && named !== '') current.path = named
|
|
414
|
+
}
|
|
217
415
|
}
|
|
218
416
|
}
|
|
219
|
-
|
|
417
|
+
if (pendingAdded > 0 || pendingRemoved > 0) current.incomplete = 'hunk-shorter-than-its-header'
|
|
418
|
+
for (const section of sections) {
|
|
419
|
+
delete section.inHeader
|
|
420
|
+
if (section.path === null) section.incomplete ??= 'unreadable-file-path'
|
|
421
|
+
}
|
|
422
|
+
return sections
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Parse `git diff` output into added files and added lines with line numbers. */
|
|
426
|
+
export function parseAddedContent(diff) {
|
|
427
|
+
return addedFilesFrom(parseDiffSections(diff))
|
|
220
428
|
}
|
|
221
429
|
|
|
222
430
|
/** Ref updates arrive on the pre-push hook's stdin as `<localRef> <localSha> <remoteRef> <remoteSha>`. */
|
|
@@ -256,12 +464,16 @@ export function parsePushRefInput(text) {
|
|
|
256
464
|
export function gitOutputResult(repoRoot, args, {
|
|
257
465
|
encoding = 'utf8',
|
|
258
466
|
gitExecutable = 'git',
|
|
467
|
+
indexFile = null,
|
|
259
468
|
maxBuffer = DIFF_RESULT_MAX_BYTES,
|
|
260
469
|
runner = spawnSync,
|
|
261
470
|
} = {}) {
|
|
262
471
|
let result
|
|
472
|
+
// A hook names the index being committed (`git commit -a` writes a temporary
|
|
473
|
+
// one); every other variable from the caller's Git environment is dropped.
|
|
474
|
+
const env = indexFile ? { ...sanitizedGitEnvironment(), GIT_INDEX_FILE: indexFile } : sanitizedGitEnvironment()
|
|
263
475
|
try {
|
|
264
|
-
result = runner(gitExecutable, ['-C', repoRoot, ...args], { encoding, maxBuffer, env
|
|
476
|
+
result = runner(gitExecutable, ['-C', repoRoot, ...args], { encoding, maxBuffer, env })
|
|
265
477
|
} catch (error) {
|
|
266
478
|
return { ok: false, code: 'git-command-failed', error: error instanceof Error ? error.message : String(error), stdout: null }
|
|
267
479
|
}
|
|
@@ -279,25 +491,34 @@ export function gitOutputResult(repoRoot, args, {
|
|
|
279
491
|
return { ok: true, stdout, bytes }
|
|
280
492
|
}
|
|
281
493
|
|
|
282
|
-
|
|
494
|
+
/** The empty tree in this repository's object format (SHA-1 or SHA-256). */
|
|
495
|
+
export function emptyTreeFor(repoRoot, { runner = spawnSync } = {}) {
|
|
496
|
+
const result = gitOutputResult(repoRoot, ['rev-parse', '--show-object-format'], { maxBuffer: 1024, runner })
|
|
497
|
+
return (result.ok && EMPTY_TREES[result.stdout.trim()]) || EMPTY_TREE
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function pushRangeBase(repoRoot, remoteSha, { runner = spawnSync } = {}) {
|
|
283
501
|
// A new branch has no remote counterpart, so everything it carries is new:
|
|
284
502
|
// diff against the empty tree rather than silently scanning nothing.
|
|
285
|
-
if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return
|
|
286
|
-
const result =
|
|
287
|
-
|
|
288
|
-
maxBuffer: 1024 * 1024,
|
|
289
|
-
})
|
|
290
|
-
return result.status === 0 ? remoteSha : EMPTY_TREE
|
|
503
|
+
if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return emptyTreeFor(repoRoot, { runner })
|
|
504
|
+
const result = gitOutputResult(repoRoot, ['rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`], { maxBuffer: 1024 * 1024, runner })
|
|
505
|
+
return result.ok ? remoteSha : emptyTreeFor(repoRoot, { runner })
|
|
291
506
|
}
|
|
292
507
|
|
|
508
|
+
export const canonicalDiffArgs = (revisions, extra = []) => ['diff', ...CANONICAL_DIFF_FLAGS, '--unified=0', ...extra, ...revisions]
|
|
509
|
+
// The content scan judges added lines only, so a deleted file's removed lines
|
|
510
|
+
// would just spend the output limit. The semantic review reads removed lines
|
|
511
|
+
// and keeps the plain form above.
|
|
512
|
+
const contentDiffArgs = (revisions, extra = []) => canonicalDiffArgs(revisions, ['--irreversible-delete', ...extra])
|
|
513
|
+
|
|
293
514
|
export function diffForPushRange(repoRoot, { localSha, remoteSha }, options = {}) {
|
|
294
|
-
const base = pushRangeBase(repoRoot, remoteSha)
|
|
295
|
-
const result = gitOutputResult(repoRoot, [
|
|
515
|
+
const base = pushRangeBase(repoRoot, remoteSha, { runner: options.runner })
|
|
516
|
+
const result = gitOutputResult(repoRoot, contentDiffArgs([base, localSha]), options)
|
|
296
517
|
return result.ok ? { ...result, base, diff: result.stdout } : { ...result, base, diff: null }
|
|
297
518
|
}
|
|
298
519
|
|
|
299
520
|
export function stagedDiff(repoRoot, options = {}) {
|
|
300
|
-
const result = gitOutputResult(repoRoot, ['
|
|
521
|
+
const result = gitOutputResult(repoRoot, contentDiffArgs(['--cached']), options)
|
|
301
522
|
return result.ok ? { ...result, diff: result.stdout } : { ...result, diff: null }
|
|
302
523
|
}
|
|
303
524
|
|
|
@@ -313,20 +534,21 @@ function incompleteDiagnostic({ repo, path: filePath = null, message, details =
|
|
|
313
534
|
}
|
|
314
535
|
}
|
|
315
536
|
|
|
316
|
-
function
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
if (current && (line.startsWith('Binary files ') || line === 'GIT binary patch')) paths.add(current)
|
|
326
|
-
}
|
|
327
|
-
return [...paths]
|
|
537
|
+
function unreadableSectionDiagnostics(sections, repo) {
|
|
538
|
+
return sections
|
|
539
|
+
.filter((section) => section.incomplete)
|
|
540
|
+
.map((section) => incompleteDiagnostic({
|
|
541
|
+
repo,
|
|
542
|
+
path: section.path,
|
|
543
|
+
message: 'a changed file could not be read from the diff, so its content was not scanned',
|
|
544
|
+
details: { reason: section.incomplete },
|
|
545
|
+
}))
|
|
328
546
|
}
|
|
329
547
|
|
|
548
|
+
const addedFilesFrom = (sections) => sections
|
|
549
|
+
.filter((section) => section.path !== null)
|
|
550
|
+
.map(({ path: filePath, added, addedLines }) => ({ path: filePath, added, addedLines }))
|
|
551
|
+
|
|
330
552
|
function isBinaryBuffer(buffer) {
|
|
331
553
|
if (buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0)) return true
|
|
332
554
|
try {
|
|
@@ -338,7 +560,6 @@ function isBinaryBuffer(buffer) {
|
|
|
338
560
|
}
|
|
339
561
|
|
|
340
562
|
function scanBinaryBuffer({ buffer, filePath, rules, exceptions, repo }) {
|
|
341
|
-
if (!isBinaryBuffer(buffer)) return []
|
|
342
563
|
const text = buffer.toString('latin1')
|
|
343
564
|
const findings = []
|
|
344
565
|
for (const rule of rules) {
|
|
@@ -359,13 +580,100 @@ function scanBinaryBuffer({ buffer, filePath, rules, exceptions, repo }) {
|
|
|
359
580
|
return findings
|
|
360
581
|
}
|
|
361
582
|
|
|
362
|
-
|
|
583
|
+
const DECLARED_TEXT_BATCH = 200
|
|
584
|
+
// A new file's diff can be twice its size (a "+" before every short line), so a
|
|
585
|
+
// batch holds at most half the diff limit in blob bytes.
|
|
586
|
+
const DECLARED_TEXT_BATCH_BYTES = DIFF_RESULT_MAX_BYTES / 2
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Git reports a file as binary when its attributes say so, whatever it holds
|
|
590
|
+
* (for example `*.cfg binary` in .gitattributes, which can arrive with the tree),
|
|
591
|
+
* or when the content it replaces was binary.
|
|
592
|
+
* Such text is judged like any other text: by its added lines, taken from a
|
|
593
|
+
* `--text` diff of just those paths and their rename sources. Narrowing the
|
|
594
|
+
* paths can change how Git pairs renames and copies, so a file's lines are used
|
|
595
|
+
* only when it is paired exactly as in the main diff; otherwise, or when a batch
|
|
596
|
+
* cannot be read, the file is read again on its own. Pathspecs are taken
|
|
597
|
+
* literally: core.precomposeUnicode is off for this call, because it would
|
|
598
|
+
* rewrite a decomposed name on the command line and match nothing. Returns, per
|
|
599
|
+
* path, `{ addedLines }` or `{ cause }` naming why the text was not read.
|
|
600
|
+
*/
|
|
601
|
+
function declaredBinaryTextLines({ repoRoot, revisions, entries, changedPaths = [], gitExecutable, gitRunner, indexFile = null }) {
|
|
602
|
+
const outcomes = new Map()
|
|
603
|
+
const read = (group) => {
|
|
604
|
+
const paths = [...new Set(group.flatMap(({ section }) => [section.path, section.oldPath].filter((value) => value && value !== '')))]
|
|
605
|
+
// A literal pathspec also matches a directory of that name, so a file that
|
|
606
|
+
// replaced a directory would bring the directory's changed files in as
|
|
607
|
+
// rename candidates and as output. Only changed entries can appear in a
|
|
608
|
+
// diff. Under a read path with nothing else read inside, one subtree
|
|
609
|
+
// exclusion covers them all; under one that holds another read path, the
|
|
610
|
+
// changed entries not read are excluded one by one. Usually there are none.
|
|
611
|
+
const reading = new Set(paths)
|
|
612
|
+
const excluded = new Set()
|
|
613
|
+
for (const read of paths) {
|
|
614
|
+
const under = changedPaths.filter((value) => value.startsWith(`${read}/`))
|
|
615
|
+
if (under.length === 0) continue
|
|
616
|
+
if (!paths.some((other) => other.startsWith(`${read}/`))) excluded.add(`${read}/`)
|
|
617
|
+
else for (const value of under) if (!reading.has(value)) excluded.add(value)
|
|
618
|
+
}
|
|
619
|
+
const result = gitOutputResult(repoRoot, [
|
|
620
|
+
'-c', 'core.precomposeUnicode=false',
|
|
621
|
+
...contentDiffArgs(revisions, ['--text']),
|
|
622
|
+
'--',
|
|
623
|
+
...paths.map((value) => `:(literal)${value}`),
|
|
624
|
+
...[...excluded].map((value) => `:(exclude,literal)${value}`),
|
|
625
|
+
], { gitExecutable, indexFile, runner: gitRunner })
|
|
626
|
+
const parsed = result.ok ? parseDiffSections(result.stdout) : []
|
|
627
|
+
for (const { section } of group) {
|
|
628
|
+
const matches = parsed.filter((candidate) => candidate.path === section.path && !candidate.deleted)
|
|
629
|
+
let cause = null
|
|
630
|
+
if (!result.ok) cause = result.code
|
|
631
|
+
else if (matches.length === 0) cause = 'missing-from-text-diff'
|
|
632
|
+
else if (matches.some((candidate) => candidate.oldPath !== section.oldPath)) cause = 'paired-differently-from-main-diff'
|
|
633
|
+
else cause = matches.find((candidate) => candidate.incomplete)?.incomplete ?? (matches.some((candidate) => candidate.binary) ? 'binary-in-text-diff' : null)
|
|
634
|
+
outcomes.set(section.path, cause ? { cause } : { addedLines: matches.flatMap((candidate) => candidate.addedLines) })
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const groups = []
|
|
638
|
+
let group = []
|
|
639
|
+
let bytes = 0
|
|
640
|
+
for (const entry of entries) {
|
|
641
|
+
if (group.length > 0 && (group.length >= DECLARED_TEXT_BATCH || bytes + entry.bytes > DECLARED_TEXT_BATCH_BYTES)) {
|
|
642
|
+
groups.push(group)
|
|
643
|
+
group = []
|
|
644
|
+
bytes = 0
|
|
645
|
+
}
|
|
646
|
+
group.push(entry)
|
|
647
|
+
bytes += entry.bytes
|
|
648
|
+
}
|
|
649
|
+
if (group.length > 0) groups.push(group)
|
|
650
|
+
for (const batch of groups) {
|
|
651
|
+
read(batch)
|
|
652
|
+
if (batch.length === 1) continue
|
|
653
|
+
for (const entry of batch) if (outcomes.get(entry.section.path).cause) read([entry])
|
|
654
|
+
}
|
|
655
|
+
return outcomes
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function readBinaryChanges({ repoRoot, revisions, sections, rules, exceptions, repo, gitExecutable, gitRunner, indexFile = null }) {
|
|
363
659
|
const findings = []
|
|
364
660
|
const diagnostics = []
|
|
365
661
|
let totalBytes = 0
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
662
|
+
const blobs = []
|
|
663
|
+
const seen = new Set()
|
|
664
|
+
const declaredText = []
|
|
665
|
+
// A deleted path is absent from the revision being scanned, so there is nothing
|
|
666
|
+
// to read: the blob it had is already in history and is not being introduced.
|
|
667
|
+
for (const section of sections) {
|
|
668
|
+
if (!section.binary || section.deleted || section.path === null || seen.has(section.path)) continue
|
|
669
|
+
seen.add(section.path)
|
|
670
|
+
const filePath = section.path
|
|
671
|
+
if (!section.newBlob) {
|
|
672
|
+
diagnostics.push(incompleteDiagnostic({ repo, path: filePath, message: 'binary content could not be identified in the diff', details: { reason: 'missing-blob-id' } }))
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
// Read by object id: a path can be misread (":0:name" names an index stage).
|
|
676
|
+
const result = gitOutputResult(repoRoot, ['cat-file', 'blob', section.newBlob], {
|
|
369
677
|
encoding: 'buffer',
|
|
370
678
|
gitExecutable,
|
|
371
679
|
maxBuffer: BINARY_FILE_MAX_BYTES + 1,
|
|
@@ -390,6 +698,7 @@ function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo,
|
|
|
390
698
|
continue
|
|
391
699
|
}
|
|
392
700
|
totalBytes += result.bytes
|
|
701
|
+
blobs.push({ id: section.newBlob, bytes: result.bytes })
|
|
393
702
|
if (totalBytes > BINARY_AGGREGATE_MAX_BYTES) {
|
|
394
703
|
diagnostics.push(incompleteDiagnostic({
|
|
395
704
|
repo,
|
|
@@ -399,9 +708,33 @@ function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo,
|
|
|
399
708
|
}))
|
|
400
709
|
break
|
|
401
710
|
}
|
|
402
|
-
|
|
711
|
+
if (isBinaryBuffer(result.stdout)) {
|
|
712
|
+
findings.push(...scanBinaryBuffer({ buffer: result.stdout, filePath, rules, exceptions, repo }))
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
declaredText.push({ section, bytes: result.bytes })
|
|
716
|
+
}
|
|
717
|
+
if (declaredText.length > 0) {
|
|
718
|
+
const changedPaths = [...new Set(sections.flatMap((section) => [section.path, section.oldPath]).filter((value) => value && value !== ''))]
|
|
719
|
+
const outcomes = declaredBinaryTextLines({ repoRoot, revisions, entries: declaredText, changedPaths, gitExecutable, gitRunner, indexFile })
|
|
720
|
+
const contentRules = rules.filter((rule) => (rule.kind ?? 'content') === 'content')
|
|
721
|
+
for (const { section } of declaredText) {
|
|
722
|
+
const { addedLines, cause } = outcomes.get(section.path)
|
|
723
|
+
if (cause) {
|
|
724
|
+
diagnostics.push(incompleteDiagnostic({
|
|
725
|
+
repo,
|
|
726
|
+
path: section.path,
|
|
727
|
+
message: cause === 'git-output-limit-exceeded'
|
|
728
|
+
? `text that Git reports as binary has a diff over the ${DIFF_RESULT_MAX_BYTES}-byte limit, so its content was not scanned`
|
|
729
|
+
: 'text that Git reports as binary could not be read as text, so its content was not scanned',
|
|
730
|
+
details: { reason: 'declared-binary-text-unreadable', cause },
|
|
731
|
+
}))
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
734
|
+
findings.push(...scanAddedContent({ files: [{ path: section.path, added: false, addedLines }], rules: contentRules, exceptions, repo }))
|
|
735
|
+
}
|
|
403
736
|
}
|
|
404
|
-
return { findings, diagnostics, bytes: totalBytes }
|
|
737
|
+
return { findings, diagnostics, bytes: totalBytes, blobs }
|
|
405
738
|
}
|
|
406
739
|
|
|
407
740
|
export function scanStagedRepository({
|
|
@@ -411,8 +744,9 @@ export function scanStagedRepository({
|
|
|
411
744
|
repo = null,
|
|
412
745
|
gitExecutable = 'git',
|
|
413
746
|
gitRunner = spawnSync,
|
|
747
|
+
indexFile = null,
|
|
414
748
|
} = {}) {
|
|
415
|
-
const acquired = stagedDiff(repoRoot, { gitExecutable, runner: gitRunner })
|
|
749
|
+
const acquired = stagedDiff(repoRoot, { gitExecutable, indexFile, runner: gitRunner })
|
|
416
750
|
if (!acquired.ok) {
|
|
417
751
|
return {
|
|
418
752
|
findings: [],
|
|
@@ -420,38 +754,169 @@ export function scanStagedRepository({
|
|
|
420
754
|
bytes: 0,
|
|
421
755
|
}
|
|
422
756
|
}
|
|
423
|
-
const
|
|
424
|
-
const binary = readBinaryChanges({ repoRoot,
|
|
757
|
+
const sections = parseDiffSections(acquired.diff)
|
|
758
|
+
const binary = readBinaryChanges({ repoRoot, revisions: ['--cached'], sections, rules, exceptions, repo, gitExecutable, gitRunner, indexFile })
|
|
425
759
|
return {
|
|
426
|
-
findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
|
|
427
|
-
diagnostics: binary.diagnostics,
|
|
760
|
+
findings: [...scanAddedContent({ files: addedFilesFrom(sections), rules, exceptions, repo }), ...binary.findings],
|
|
761
|
+
diagnostics: [...unreadableSectionDiagnostics(sections, repo), ...binary.diagnostics],
|
|
428
762
|
bytes: acquired.bytes + binary.bytes,
|
|
429
763
|
}
|
|
430
764
|
}
|
|
431
765
|
|
|
766
|
+
function scanRevisionRange({ repoRoot, base, tip, rules, exceptions, repo, gitRunner }) {
|
|
767
|
+
const acquired = gitOutputResult(repoRoot, contentDiffArgs([base, tip]), { runner: gitRunner })
|
|
768
|
+
if (!acquired.ok) return { acquired, keyed: [], diagnostics: [], bytes: 0, blobs: [] }
|
|
769
|
+
const sections = parseDiffSections(acquired.stdout)
|
|
770
|
+
const binary = readBinaryChanges({ repoRoot, revisions: [base, tip], sections, rules, exceptions, repo, gitRunner })
|
|
771
|
+
const findings = [...scanAddedContent({ files: addedFilesFrom(sections), rules, exceptions, repo }), ...binary.findings]
|
|
772
|
+
return {
|
|
773
|
+
acquired,
|
|
774
|
+
keyed: findings.map((item) => [findingKey(item), item]),
|
|
775
|
+
diagnostics: [...unreadableSectionDiagnostics(sections, repo), ...binary.diagnostics],
|
|
776
|
+
bytes: acquired.bytes + binary.bytes,
|
|
777
|
+
blobs: binary.blobs,
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// What a finding reports: its rule, path and the digest of its line (binary
|
|
782
|
+
// content has no line), so the same content is recognised in any diff.
|
|
783
|
+
function findingKey(finding) {
|
|
784
|
+
return [finding.rule, finding.path, finding.lineDigest ?? 'binary'].join('\0')
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* What one commit adds. A merge adds only what is new against every parent:
|
|
789
|
+
* a side parent's own commits are judged where they were made, and content the
|
|
790
|
+
* remote already holds is not judged again. A finding is dropped only when a
|
|
791
|
+
* fully read diff against another parent shows that parent already had it, and
|
|
792
|
+
* one fully read parent diff covers everything new against all parents.
|
|
793
|
+
*/
|
|
794
|
+
function scanCommit({ repoRoot, commit, parents, empty, rules, exceptions, repo, gitRunner }) {
|
|
795
|
+
if (parents.length <= 1) return scanRevisionRange({ repoRoot, base: parents[0] ?? empty, tip: commit, rules, exceptions, repo, gitRunner })
|
|
796
|
+
const read = parents
|
|
797
|
+
.map((parent) => scanRevisionRange({ repoRoot, base: parent, tip: commit, rules, exceptions, repo, gitRunner }))
|
|
798
|
+
.filter((scan) => scan.acquired.ok)
|
|
799
|
+
if (read.length === 0) return { acquired: { ok: false, code: 'merge-unreadable' }, keyed: [], diagnostics: [], bytes: 0, blobs: [] }
|
|
800
|
+
const complete = read.filter((scan) => scan.diagnostics.length === 0)
|
|
801
|
+
// Any complete parent diff yields the same findings; the smallest is the one
|
|
802
|
+
// that does not re-read what the other parent already published.
|
|
803
|
+
const reference = complete.length > 0 ? complete.reduce((small, scan) => (scan.bytes < small.bytes ? scan : small)) : read[0]
|
|
804
|
+
const keyed = reference.keyed.filter(([key]) => complete.every((scan) => scan === reference || scan.keyed.some(([other]) => other === key)))
|
|
805
|
+
const diagnostics = complete.length > 0 ? [] : read.flatMap((scan) => scan.diagnostics)
|
|
806
|
+
// Evidence counts once, as the diff the commit is judged by.
|
|
807
|
+
return { acquired: reference.acquired, keyed, diagnostics, bytes: reference.bytes, blobs: reference.blobs }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const PLAIN_REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Which tracking refs already hold what this push carries. The installed hook
|
|
814
|
+
* passes the remote Git names, which is a configured remote or a URL; a URL is
|
|
815
|
+
* matched to the remote configured with it. Only that remote's tracking refs
|
|
816
|
+
* exclude commits, so history held only by another remote is judged on its way
|
|
817
|
+
* here. A URL no remote is configured with excludes nothing but the remote's
|
|
818
|
+
* own commit. With no remote named (a hook installed before this), every
|
|
819
|
+
* remote's tracking refs exclude, as before.
|
|
820
|
+
*/
|
|
821
|
+
function remoteScope(repoRoot, remote, runner) {
|
|
822
|
+
if (typeof remote !== 'string' || remote === '') return { args: ['--remotes'], label: 'no remote holds' }
|
|
823
|
+
const configured = gitOutputResult(repoRoot, ['config', '--get-regexp', '^remote\\..*\\.url$'], { maxBuffer: 1024 * 1024, runner })
|
|
824
|
+
const remotes = configured.ok
|
|
825
|
+
? configured.stdout.split('\n').filter(Boolean).map((line) => {
|
|
826
|
+
const space = line.indexOf(' ')
|
|
827
|
+
return { name: line.slice('remote.'.length, space).replace(/\.url$/, ''), url: line.slice(space + 1) }
|
|
828
|
+
})
|
|
829
|
+
: []
|
|
830
|
+
const named = remotes.find((item) => item.name === remote) ?? remotes.find((item) => item.url === remote)
|
|
831
|
+
if (named && PLAIN_REMOTE_NAME.test(named.name)) {
|
|
832
|
+
// `--remotes=origin` would also take a remote named `origin/x`; leave those out.
|
|
833
|
+
const nested = remotes.filter((item) => item.name.startsWith(`${named.name}/`)).map((item) => `--exclude=${item.name}/*`)
|
|
834
|
+
return { args: [...nested, `--remotes=${named.name}`], label: `${named.name} does not hold` }
|
|
835
|
+
}
|
|
836
|
+
return { args: [], label: `${remote} does not hold` }
|
|
837
|
+
}
|
|
838
|
+
|
|
432
839
|
export function scanPushUpdate({
|
|
433
840
|
repoRoot,
|
|
434
841
|
update,
|
|
435
842
|
rules = DEFAULT_CONTENT_RULES,
|
|
436
843
|
exceptions = [],
|
|
437
844
|
repo = null,
|
|
845
|
+
remote = null,
|
|
438
846
|
gitRunner = spawnSync,
|
|
847
|
+
maxCommits = PUSH_COMMIT_SCAN_MAX,
|
|
848
|
+
maxBytes = CHECK_AGGREGATE_MAX_BYTES,
|
|
439
849
|
} = {}) {
|
|
440
|
-
const
|
|
441
|
-
|
|
850
|
+
const base = pushRangeBase(repoRoot, update.remoteSha, { runner: gitRunner })
|
|
851
|
+
const net = scanRevisionRange({ repoRoot, base, tip: update.localSha, rules, exceptions, repo, gitRunner })
|
|
852
|
+
if (!net.acquired.ok) {
|
|
442
853
|
return {
|
|
443
854
|
findings: [],
|
|
444
|
-
diagnostics: [incompleteDiagnostic({ repo, message: 'push evidence could not be acquired', details: { reason: acquired.code } })],
|
|
855
|
+
diagnostics: [incompleteDiagnostic({ repo, message: 'push evidence could not be acquired', details: { reason: net.acquired.code } })],
|
|
445
856
|
bytes: 0,
|
|
446
857
|
}
|
|
447
858
|
}
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
859
|
+
const reported = new Map(net.keyed)
|
|
860
|
+
const findings = net.keyed.map(([, item]) => item)
|
|
861
|
+
const diagnostics = [...net.diagnostics]
|
|
862
|
+
// Every path is judged wherever it appears, but each blob counts once toward
|
|
863
|
+
// the evidence budget, however many diffs read it.
|
|
864
|
+
const counted = new Set()
|
|
865
|
+
const evidence = (scan) => scan.bytes - scan.blobs.reduce((repeat, blob) => {
|
|
866
|
+
if (counted.has(blob.id)) return repeat + blob.bytes
|
|
867
|
+
counted.add(blob.id)
|
|
868
|
+
return repeat
|
|
869
|
+
}, 0)
|
|
870
|
+
let bytes = evidence(net)
|
|
871
|
+
const scope = remoteScope(repoRoot, remote, gitRunner)
|
|
872
|
+
// Parents before children, so a commit is judged before a merge that brings it in.
|
|
873
|
+
const listed = gitOutputResult(repoRoot, ['rev-list', '--topo-order', '--reverse', '--parents', `--max-count=${maxCommits + 1}`, update.localSha, '--not', ...(base === update.remoteSha ? [base] : []), ...scope.args], { runner: gitRunner })
|
|
874
|
+
if (!listed.ok) {
|
|
875
|
+
diagnostics.push(incompleteDiagnostic({ repo, message: 'the commits in this push could not be listed', details: { reason: listed.code } }))
|
|
876
|
+
return { findings, diagnostics, bytes }
|
|
877
|
+
}
|
|
878
|
+
const commits = listed.stdout.split('\n').filter(Boolean).map((line) => line.split(' '))
|
|
879
|
+
if (commits.length > maxCommits) {
|
|
880
|
+
diagnostics.push(incompleteDiagnostic({
|
|
881
|
+
repo,
|
|
882
|
+
message: `this push carries more than ${maxCommits} commits that ${scope.label}, more than are judged one by one; push it in smaller parts, an earlier commit first (git push <remote> <commit>:<ref>)`,
|
|
883
|
+
details: { reason: 'push-range-too-long', limit: maxCommits },
|
|
884
|
+
}))
|
|
885
|
+
return { findings, diagnostics, bytes }
|
|
886
|
+
}
|
|
887
|
+
// One commit straight on the remote's tip is the endpoint change already judged.
|
|
888
|
+
if (commits.length === 1 && commits[0].length === 2 && commits[0][1] === base) return { findings, diagnostics, bytes }
|
|
889
|
+
const empty = emptyTreeFor(repoRoot, { runner: gitRunner })
|
|
890
|
+
for (const [commit, ...parents] of commits) {
|
|
891
|
+
const step = scanCommit({ repoRoot, commit, parents, empty, rules, exceptions, repo, gitRunner })
|
|
892
|
+
const short = commit.slice(0, 12)
|
|
893
|
+
if (!step.acquired.ok) {
|
|
894
|
+
diagnostics.push(incompleteDiagnostic({ repo, message: `commit ${short} could not be read`, details: { reason: step.acquired.code, commit } }))
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
bytes += evidence(step)
|
|
898
|
+
for (const [key, item] of step.keyed) {
|
|
899
|
+
const retained = reported.get(key)
|
|
900
|
+
if (retained) {
|
|
901
|
+
// Already reported: record where else it was published.
|
|
902
|
+
if (!retained.commit) retained.commits = [...(retained.commits ?? []), commit]
|
|
903
|
+
continue
|
|
904
|
+
}
|
|
905
|
+
const placed = { ...item, commit, message: `${item.message} (in commit ${short})` }
|
|
906
|
+
reported.set(key, placed)
|
|
907
|
+
findings.push(placed)
|
|
908
|
+
}
|
|
909
|
+
diagnostics.push(...step.diagnostics.map((item) => ({ ...item, message: `${item.message} (in commit ${short})`, details: { ...item.details, commit } })))
|
|
910
|
+
if (bytes > maxBytes) {
|
|
911
|
+
diagnostics.push(incompleteDiagnostic({
|
|
912
|
+
repo,
|
|
913
|
+
message: `push evidence exceeds the ${maxBytes}-byte aggregate limit; push it in smaller parts`,
|
|
914
|
+
details: { reason: 'push-evidence-too-large', limitBytes: maxBytes, commit },
|
|
915
|
+
}))
|
|
916
|
+
break
|
|
917
|
+
}
|
|
454
918
|
}
|
|
919
|
+
return { findings, diagnostics, bytes }
|
|
455
920
|
}
|
|
456
921
|
|
|
457
922
|
/**
|