@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +233 -0
  2. package/README.md +22 -5
  3. package/docs/blocks/capability-stewardship.md +1 -1
  4. package/docs/blocks/learning-harnesses.md +1 -1
  5. package/docs/capability-stewardship.md +2 -4
  6. package/docs/ingestion.md +3 -3
  7. package/docs/inquiry-harnesses.md +2 -3
  8. package/docs/install.md +3 -3
  9. package/docs/learning-harnesses.md +2 -3
  10. package/docs/local-services.md +42 -3
  11. package/docs/obsidian-contract.md +23 -0
  12. package/docs/obsidian-plugin.md +214 -3
  13. package/docs/obsidian.md +271 -11
  14. package/docs/personal-workspace.md +13 -4
  15. package/docs/release-engineering.md +2 -1
  16. package/docs/repo-boundary-guard.md +26 -3
  17. package/docs/semantic-operation.md +4 -3
  18. package/docs/upgrade.md +174 -2
  19. package/fixtures/templates/local-library.v1.json +2 -1
  20. package/package.json +2 -1
  21. package/plugins/obsidian/main.js +57 -8
  22. package/plugins/obsidian/manifest.json +1 -1
  23. package/src/boundary/content-rules.mjs +532 -67
  24. package/src/boundary/policy.mjs +135 -56
  25. package/src/cli/run.mjs +3 -2
  26. package/src/commands/obsidian.mjs +23 -7
  27. package/src/graph/graph.mjs +13 -5
  28. package/src/graph/knowledge-graph.mjs +19 -1
  29. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  30. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  31. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  32. package/src/knowledge-health/rule-identity.mjs +14 -9
  33. package/src/personal-workspace/index.mjs +1 -1
  34. package/src/personal-workspace/selection.mjs +17 -1
  35. package/src/project/path-match.mjs +2 -1
  36. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  37. package/src/projection/obsidian/personal-workspace.mjs +87 -4
  38. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  39. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  40. package/src/projection/obsidian/publication/publisher.mjs +72 -5
  41. package/src/projection/project.mjs +3 -2
  42. package/src/runtime/obsidian/engine.mjs +293 -55
  43. package/src/runtime/obsidian/lifecycle.mjs +17 -12
  44. package/src/runtime/obsidian/login-item.mjs +4 -1
  45. package/src/runtime/obsidian/observation.mjs +36 -4
  46. package/src/runtime/obsidian/opening.mjs +3 -2
  47. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  48. package/src/runtime/obsidian/pipeline.mjs +44 -3
  49. package/src/runtime/obsidian/private-lock.mjs +39 -4
  50. package/src/runtime/obsidian/service-main.mjs +58 -12
  51. package/src/runtime/obsidian/service.mjs +66 -6
  52. package/src/runtime/obsidian/view-permission.mjs +334 -0
  53. package/src/ui/control-system.mjs +201 -0
  54. package/src/ui/html-primitives.mjs +3 -0
  55. package/src/upgrade/closure-diagnostics.mjs +135 -0
  56. package/src/upgrade/closure.mjs +649 -0
  57. package/src/upgrade/upgrade.mjs +19 -7
  58. 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
- const filePath = normalizeRelPath(file.path)
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
- if (!file.added || !new RegExp(rule.pattern, 'i').test(filePath)) continue
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
- findings.push({
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
- /** Parse `git diff` output into added files and added lines with line numbers. */
189
- export function parseAddedContent(diff) {
190
- const files = []
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 header = raw.match(/^diff --git a\/(.*) b\/(.*)$/)
196
- current = { path: header ? normalizeRelPath(header[2]) : null, added: false, addedLines: [] }
197
- files.push(current)
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 (!current) continue
201
- if (raw.startsWith('new file mode ')) {
202
- current.added = true
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
- const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
374
+ if (!current) continue
375
+ if (raw.startsWith('\\')) continue
376
+ const hunk = raw.match(HUNK_HEADER)
206
377
  if (hunk) {
207
- lineNumber = Number(hunk[1])
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 (/^\+(?!\+)/.test(raw)) {
211
- current.addedLines.push({ number: lineNumber, text: raw.slice(1) })
212
- lineNumber += 1
213
- } else if (/^-(?!-)/.test(raw)) {
214
- // removed lines do not advance the new-file line counter
215
- } else if (raw.startsWith(' ')) {
216
- lineNumber += 1
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
- return files.filter((file) => file.path)
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: sanitizedGitEnvironment() })
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
- export function pushRangeBase(repoRoot, remoteSha) {
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 EMPTY_TREE
286
- const result = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`], {
287
- encoding: 'utf8',
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, ['diff', '--unified=0', base, localSha], options)
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, ['diff', '--cached', '--unified=0'], options)
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 binaryPathsFromDiff(diff) {
317
- const paths = new Set()
318
- let current = null
319
- for (const line of String(diff ?? '').split('\n')) {
320
- if (line.startsWith('diff --git ')) {
321
- const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
322
- current = header ? normalizeRelPath(header[2]) : null
323
- continue
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
- function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo, gitExecutable, gitRunner }) {
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
- for (const filePath of binaryPathsFromDiff(diff)) {
367
- const spec = revision === ':' ? `:${filePath}` : `${revision}:${filePath}`
368
- const result = gitOutputResult(repoRoot, ['show', spec], {
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
- findings.push(...scanBinaryBuffer({ buffer: result.stdout, filePath, rules, exceptions, repo }))
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 files = parseAddedContent(acquired.diff)
424
- const binary = readBinaryChanges({ repoRoot, revision: ':', diff: acquired.diff, rules, exceptions, repo, gitExecutable, gitRunner })
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 acquired = diffForPushRange(repoRoot, update, { runner: gitRunner })
441
- if (!acquired.ok) {
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 files = parseAddedContent(acquired.diff)
449
- const binary = readBinaryChanges({ repoRoot, revision: update.localSha, diff: acquired.diff, rules, exceptions, repo })
450
- return {
451
- findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
452
- diagnostics: binary.diagnostics,
453
- bytes: acquired.bytes + binary.bytes,
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
  /**