euthyna 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,6 +62,9 @@ export function parseDeletedRanges(diffText) {
62
62
  */
63
63
  export async function blameRanges({ cwd, rev, file, ranges }) {
64
64
  const byCommit = new Map();
65
+ // ponytail: 顺序 blame,每个区间一次 git 进程,耗时与删除区间数线性相关
66
+ // (基准见 bench/perf.js)。若吞吐不满足要求:改成每文件一次 blame 覆盖
67
+ // 全部区间,或对单行区间做有界并发。
65
68
  for (const range of ranges) {
66
69
  const end = range.start + range.count - 1;
67
70
  const out = await git(
@@ -109,6 +112,66 @@ export function compressRanges(lineNumbers) {
109
112
  return ranges;
110
113
  }
111
114
 
115
+ /**
116
+ * Parse `git diff --unified=0` into the deleted lines with their old line
117
+ * numbers: [{ line, content }]. With --unified=0 every hunk is exactly the
118
+ * changed lines, so the old line numbers walk from the hunk's old start across
119
+ * the deleted lines.
120
+ */
121
+ export function parseDeletedLines(diffText) {
122
+ const out = [];
123
+ let oldLine = null;
124
+ for (const raw of diffText.split('\n')) {
125
+ const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
126
+ if (hunk) {
127
+ oldLine = Number(hunk[1]);
128
+ continue;
129
+ }
130
+ // A deleted line inside a hunk always starts with '-', even when its own
131
+ // content starts with '-' or '--' (git writes `---foo` for content
132
+ // `--foo`). The `--- a/file` headers only ever appear outside hunks, where
133
+ // oldLine is null, so they are the ones excluded here — not the deleted
134
+ // lines themselves.
135
+ if (raw.startsWith('-') && oldLine !== null) {
136
+ out.push({ line: oldLine++, content: raw.slice(1) });
137
+ continue;
138
+ }
139
+ if (raw.startsWith('+') && !raw.startsWith('+++')) continue;
140
+ if (raw.startsWith(' ')) {
141
+ if (oldLine !== null) oldLine++;
142
+ continue;
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** True when any of the given line contents is itself security vocabulary. */
149
+ export function securityRelevantLines(lines, { securityPattern = SECURITY_PATTERN } = {}) {
150
+ return lines.some(line => securityPattern.test(line));
151
+ }
152
+
153
+ /**
154
+ * Classify a commit the way an adjudicator would read it: the message first,
155
+ * and when the message is neutral, whether the commit's own diff touched lines
156
+ * that are themselves security vocabulary — removing or changing a sanitizer,
157
+ * an authorization check, a credential guard. The message stays the primary
158
+ * signal; the diff is the fallback for "update utils" commits whose subject
159
+ * says nothing but whose changed lines are unambiguously security.
160
+ */
161
+ export async function classifyCommit(
162
+ cwd,
163
+ hash,
164
+ subject,
165
+ { securityPattern = SECURITY_PATTERN, fixPattern = FIX_PATTERN } = {}
166
+ ) {
167
+ const byMessage = classifySubject(subject, { securityPattern, fixPattern });
168
+ if (byMessage !== 'none') return byMessage;
169
+ const out = await git(['show', '--format=', '--unified=0', hash], { cwd, allowFailure: true });
170
+ if (!out) return 'none';
171
+ const changed = out.split('\n').filter(line => /^[+-]/.test(line) && !/^(\+\+\+|---)/.test(line));
172
+ return changed.some(line => securityPattern.test(line.slice(1))) ? 'security' : 'none';
173
+ }
174
+
112
175
  /** Classify a commit subject. Returns 'security', 'fix', or 'none'. */
113
176
  export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, fixPattern = FIX_PATTERN } = {}) {
114
177
  if (!subject) return 'none';
@@ -120,12 +183,29 @@ export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, f
120
183
  /**
121
184
  * Collect history facts for a revision range.
122
185
  *
186
+ * Attribution semantics, stated explicitly because they are the point of this
187
+ * function and easy to over-claim:
188
+ *
189
+ * - blame (the default) answers "who last touched the line" — a security-fix
190
+ * line that a later formatting/refactor commit moved is attributed to the
191
+ * formatter, not the fix. `--origins` answers "who FIRST introduced the
192
+ * line's content" by probing `git log -S` and re-attributes the line when
193
+ * the introducing commit differs and classifies security or fix.
194
+ * - classification looks at the commit message first, then at whether the
195
+ * deleted line content itself is security vocabulary, then at whether the
196
+ * commit's own diff touched security-vocabulary lines. All three err broad
197
+ * on purpose: under-classifying hides exactly the deleted-code-origin case
198
+ * this tool exists to surface.
199
+ *
123
200
  * @param {object} options
124
201
  * @param {string} options.cwd
125
202
  * @param {string} options.base
126
203
  * @param {string} options.head
127
204
  * @param {boolean} [options.pickaxe] also look for reintroduced lines
128
205
  * @param {number} [options.maxPickaxe] cap on pickaxe probes (reported when hit)
206
+ * @param {boolean} [options.origins] re-attribute deleted lines to the commit
207
+ * that first introduced their content
208
+ * @param {number} [options.maxOrigins] cap on origin probes (reported when hit)
129
209
  */
130
210
  export async function collectHistoryFacts({
131
211
  cwd,
@@ -133,6 +213,8 @@ export async function collectHistoryFacts({
133
213
  head = 'HEAD',
134
214
  pickaxe = false,
135
215
  maxPickaxe = 40,
216
+ origins = false,
217
+ maxOrigins = 40,
136
218
  securityPattern = SECURITY_PATTERN,
137
219
  fixPattern = FIX_PATTERN
138
220
  } = {}) {
@@ -156,6 +238,7 @@ export async function collectHistoryFacts({
156
238
  // attributable to an earlier commit, so it is reported rather than dropped.
157
239
  const blameByCommit = new Map();
158
240
  const linesByFile = new Map();
241
+ const deletedLinesByFile = new Map();
159
242
 
160
243
  for (const file of files) {
161
244
  const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd });
@@ -164,6 +247,7 @@ export async function collectHistoryFacts({
164
247
 
165
248
  const deletedLines = ranges.reduce((sum, r) => sum + r.count, 0);
166
249
  linesByFile.set(file, { ranges, deletedLines });
250
+ deletedLinesByFile.set(file, parseDeletedLines(diff));
167
251
 
168
252
  const byCommit = await blameRanges({ cwd, rev: base, file, ranges });
169
253
  for (const [hash, lineNumbers] of byCommit) {
@@ -194,17 +278,206 @@ export async function collectHistoryFacts({
194
278
  }
195
279
 
196
280
  const summaries = await commitSummaries(cwd, [...blameByCommit.keys()]);
281
+ // classifyCommit is cached: the same commit can be a blame owner and an
282
+ // origin, and each costs a `git show`.
283
+ const classifyCache = new Map();
284
+ const classify = async (hash, subject) => {
285
+ if (classifyCache.has(hash)) return classifyCache.get(hash);
286
+ const result = await classifyCommit(cwd, hash, subject, { securityPattern, fixPattern });
287
+ classifyCache.set(hash, result);
288
+ return result;
289
+ };
290
+
291
+ // Origin refinement (--origins): every deleted line whose blame owner is not
292
+ // itself the first introducer is probed with `git log -S` to find the commit
293
+ // that FIRST introduced its content. If that commit classifies security or
294
+ // fix and differs from the blame owner, the line is re-attributed to it —
295
+ // the format commit that moved a security line stops masquerading as its
296
+ // origin. No blame group is skipped up front: a commit whose subject says
297
+ // "security" can still be a reformatting commit whose lines have an earlier
298
+ // introducer, and --origins is precisely about not trusting blame's answer.
299
+ // The probe cap (maxOrigins) bounds the cost.
300
+ const originGroups = new Map(); // blameHash -> Map<originHash, group>
301
+ if (origins) {
302
+ let probed = 0;
303
+ let capped = false;
304
+ let shortSkipped = 0;
305
+ for (const [hash, entry] of blameByCommit) {
306
+ if (capped) break;
307
+ for (const [file, lineNumbers] of entry.byFile) {
308
+ if (capped) break;
309
+ const deleted = deletedLinesByFile.get(file) ?? [];
310
+ const contentByLine = new Map(deleted.map(d => [d.line, d.content]));
311
+ const contents = [
312
+ ...new Set(
313
+ lineNumbers
314
+ .map(n => contentByLine.get(n))
315
+ .filter(c => typeof c === 'string' && c.length > 0)
316
+ )
317
+ ];
318
+ for (const content of contents) {
319
+ // Generic short lines (a closing brace, `return x;`) occur in almost
320
+ // every commit, so their "-S" answer is the first commit of the whole
321
+ // history, not an origin. Only contents long enough to be specific
322
+ // are probed; the rest keep the blame attribution.
323
+ if (content.length < MIN_PICKAXE_LINE_LENGTH) {
324
+ shortSkipped++;
325
+ continue;
326
+ }
327
+ if (probed >= maxOrigins) {
328
+ capped = true;
329
+ break;
330
+ }
331
+ probed++;
332
+ const log = await git(['log', '--format=%H', `-S${content}`, base, '--', file], {
333
+ cwd,
334
+ allowFailure: true
335
+ });
336
+ const hashes = log.split('\n').map(s => s.trim()).filter(Boolean);
337
+ if (hashes.length === 0) continue;
338
+ // git log lists newest first; the last entry is the oldest, i.e. the
339
+ // commit where the content's occurrence count first rose — the one
340
+ // that introduced it.
341
+ const origin = hashes[hashes.length - 1];
342
+ if (origin === hash) continue;
343
+ const originSummary = (await commitSummaries(cwd, [origin])).get(origin);
344
+ const originClassification = await classify(origin, originSummary?.subject ?? null);
345
+ if (originClassification === 'none') continue;
346
+
347
+ const lines = lineNumbers.filter(n => contentByLine.get(n) === content);
348
+ let byOrigin = originGroups.get(hash)?.get(origin);
349
+ if (!byOrigin) {
350
+ byOrigin = {
351
+ classification: originClassification,
352
+ summary: originSummary ?? null,
353
+ byFile: new Map(),
354
+ contents: new Map()
355
+ };
356
+ if (!originGroups.has(hash)) originGroups.set(hash, new Map());
357
+ originGroups.get(hash).set(origin, byOrigin);
358
+ }
359
+ const existing = byOrigin.byFile.get(file) ?? [];
360
+ byOrigin.byFile.set(file, [...existing, ...lines]);
361
+ // One origin commit can account for several distinct deleted lines in
362
+ // the same file; every content is kept so the fact can reproduce each.
363
+ const existingContents = byOrigin.contents.get(file) ?? [];
364
+ byOrigin.contents.set(file, [...existingContents, content]);
365
+ }
366
+ }
367
+ }
368
+ if (capped) {
369
+ notEvaluated.push(
370
+ notEvaluatedEntry(
371
+ 'history-origins',
372
+ `git log -S 来源探针上限 ${maxOrigins} 已用尽,其余删除行保留 blame 归属`
373
+ )
374
+ );
375
+ }
376
+ if (shortSkipped > 0) {
377
+ notEvaluated.push(
378
+ notEvaluatedEntry(
379
+ 'history-origins',
380
+ `${shortSkipped} 行内容过短(< ${MIN_PICKAXE_LINE_LENGTH} 字符),` +
381
+ '未做来源追溯(太通用时 -S 会指向整个历史的第一个提交而非真实来源),保留 blame 归属'
382
+ )
383
+ );
384
+ }
385
+ } else if (linesByFile.size > 0) {
386
+ notEvaluated.push(
387
+ notEvaluatedEntry(
388
+ 'history-origins',
389
+ '未启用 --origins,删除行的归属基于 git blame 的「最后修改」语义;' +
390
+ '被删行本身或提交 diff 含安全关键词时仍会标为 security'
391
+ )
392
+ );
393
+ }
394
+
395
+ // Split every blame group into its remainder (blame attribution) and its
396
+ // refined subsets (one fact per origin commit). Each becomes a fact.
397
+ const pending = [];
398
+ for (const [hash, entry] of blameByCommit) {
399
+ const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
400
+ const groups = originGroups.get(hash);
401
+ if (!groups) {
402
+ pending.push({ hash, entry, summary, originMethod: 'blame' });
403
+ continue;
404
+ }
405
+ // Line numbers are only meaningful per file: a refined line 10 in one file
406
+ // must not remove line 10 from the remainder of an unrelated file. The
407
+ // refined set is therefore keyed by file.
408
+ const refinedByFile = new Map();
409
+ for (const group of groups.values()) {
410
+ for (const [file, lineNumbers] of group.byFile) {
411
+ const set = refinedByFile.get(file) ?? new Set();
412
+ for (const n of lineNumbers) set.add(n);
413
+ refinedByFile.set(file, set);
414
+ }
415
+ }
416
+ const remainderByFile = new Map();
417
+ for (const [file, lineNumbers] of entry.byFile) {
418
+ const rest = lineNumbers.filter(n => !(refinedByFile.get(file)?.has(n) ?? false));
419
+ if (rest.length) remainderByFile.set(file, rest);
420
+ }
421
+ if (remainderByFile.size) {
422
+ pending.push({
423
+ hash,
424
+ entry: {
425
+ lines: [...remainderByFile.values()].reduce((sum, a) => sum + a.length, 0),
426
+ byFile: remainderByFile
427
+ },
428
+ summary,
429
+ originMethod: 'blame'
430
+ });
431
+ }
432
+ for (const [originHash, group] of groups) {
433
+ pending.push({
434
+ hash: originHash,
435
+ entry: {
436
+ lines: [...group.byFile.values()].reduce((sum, a) => sum + a.length, 0),
437
+ byFile: group.byFile
438
+ },
439
+ summary: group.summary ?? { subject: '(提交信息不可读)', author: null, date: null },
440
+ originMethod: 'pickaxe',
441
+ blameCommit: hash,
442
+ contents: group.contents
443
+ });
444
+ }
445
+ }
446
+
447
+ // Classify each pending fact. Order of signals, from most to least direct:
448
+ // the origin commit's message, then the deleted line content itself (the
449
+ // deleted code is security code even when the commit that owns it says
450
+ // nothing), then the origin commit's own diff (changed security-vocabulary
451
+ // lines in an otherwise neutral commit). The basis is recorded so
452
+ // "分类:security" is never read as stronger than the evidence behind it.
453
+ const ranked = [];
454
+ for (const item of pending) {
455
+ const byMessage = classifySubject(item.summary.subject, { securityPattern, fixPattern });
456
+ let basis = byMessage !== 'none' ? 'message' : 'none';
457
+ let classification = byMessage;
458
+ if (classification === 'none') {
459
+ const contents = [...item.entry.byFile.entries()].flatMap(([file, lineNumbers]) => {
460
+ const deleted = deletedLinesByFile.get(file) ?? [];
461
+ const byLine = new Map(deleted.map(d => [d.line, d.content]));
462
+ return lineNumbers.map(n => byLine.get(n)).filter(c => typeof c === 'string');
463
+ });
464
+ if (securityRelevantLines(contents, { securityPattern })) {
465
+ classification = 'security';
466
+ basis = 'deleted-line';
467
+ }
468
+ }
469
+ if (classification === 'none') {
470
+ classification = await classify(item.hash, item.summary.subject);
471
+ if (classification === 'security') basis = 'diff';
472
+ }
473
+ ranked.push({ ...item, classification, basis });
474
+ }
197
475
 
198
476
  // Report the security-relevant origins first: they are what an adjudicator
199
477
  // must look at, and the ordering is stable so two runs produce the same report.
200
- const ranked = [...blameByCommit.entries()]
201
- .map(([hash, entry]) => {
202
- const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
203
- return { hash, entry, summary, classification: classifySubject(summary.subject, { securityPattern, fixPattern }) };
204
- })
205
- .sort((a, b) => rank(a.classification) - rank(b.classification) || b.entry.lines - a.entry.lines);
206
-
207
- for (const { hash, entry, summary, classification } of ranked) {
478
+ ranked.sort((a, b) => rank(a.classification) - rank(b.classification) || b.entry.lines - a.entry.lines);
479
+
480
+ for (const { hash, entry, summary, classification, basis, originMethod, blameCommit, contents } of ranked) {
208
481
  const byFile = [...entry.byFile.entries()]
209
482
  .map(([file, lineNumbers]) => ({
210
483
  file,
@@ -214,17 +487,54 @@ export async function collectHistoryFacts({
214
487
  .sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file));
215
488
 
216
489
  const fileCount = byFile.length;
490
+ const primary = byFile[0];
217
491
  // Say how many files the lines are spread over. Reporting a total against a
218
492
  // single file path reads as "all of these are here", which sends a reader to
219
493
  // the wrong place and makes an accurate attribution look wrong.
494
+ const originPhrase =
495
+ originMethod === 'pickaxe'
496
+ ? `本次变更删除了 ${entry.lines} 行代码,其内容最初由提交 ${hash.slice(0, 10)} 引入` +
497
+ `(git blame 的最后修改者是 ${blameCommit.slice(0, 10)})`
498
+ : `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码`;
220
499
  const statement =
221
- `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码` +
500
+ originPhrase +
222
501
  (fileCount > 1 ? `,分布在 ${fileCount} 个文件` : `(文件:${byFile[0].file})`) +
223
502
  `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`;
224
503
 
504
+ const detail = {
505
+ classification,
506
+ classificationBasis: basis,
507
+ originMethod,
508
+ commitSubject: summary.subject,
509
+ commitAuthor: summary.author,
510
+ commitDate: summary.date,
511
+ blamedLines: entry.lines,
512
+ byFile,
513
+ // Stated explicitly so a reader is not left thinking the command above
514
+ // covers lines in the other files too.
515
+ reproductionNote:
516
+ fileCount > 1
517
+ ? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
518
+ : '上面的命令复现本事实涉及的全部行'
519
+ };
520
+
521
+ if (originMethod === 'pickaxe') {
522
+ detail.blameCommit = blameCommit;
523
+ detail.originCommit = hash;
524
+ detail.contents = Object.fromEntries(
525
+ [...contents.entries()].map(([file, list]) => [file, list.map(c => c.slice(0, 400))])
526
+ );
527
+ }
528
+
225
529
  // Multiple -L flags reproduce every attributed line in the primary file.
226
- const primary = byFile[0];
227
530
  const lineFlags = primary.ranges.map(r => `-L ${r.start},${r.end}`).join(' ');
531
+ const command =
532
+ originMethod === 'pickaxe'
533
+ ? // The reproducible claim is "this commit introduced that content". The
534
+ // full first probed content is used, not a truncation: a truncated -S
535
+ // argument would not reproduce the probe that established the claim.
536
+ `git log --format=%H -S${shellQuote(contents.get(primary.file)[0])} ${base} -- ${shellQuote(primary.file)}`
537
+ : `git blame --porcelain ${lineFlags} ${base} -- ${shellQuote(primary.file)}`;
228
538
 
229
539
  facts.push(
230
540
  makeFact({
@@ -234,21 +544,8 @@ export async function collectHistoryFacts({
234
544
  status: STATUS.ESTABLISHED,
235
545
  evidence: { file: primary.file, commit: hash, files: byFile.map(f => f.file) },
236
546
  method: 'command',
237
- command: `git blame --porcelain ${lineFlags} ${base} -- ${shellQuote(primary.file)}`,
238
- detail: {
239
- classification,
240
- commitSubject: summary.subject,
241
- commitAuthor: summary.author,
242
- commitDate: summary.date,
243
- blamedLines: entry.lines,
244
- byFile,
245
- // Stated explicitly so a reader is not left thinking the command above
246
- // covers lines in the other files too.
247
- reproductionNote:
248
- fileCount > 1
249
- ? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
250
- : '上面的命令复现本事实涉及的全部行'
251
- }
547
+ command,
548
+ detail
252
549
  })
253
550
  );
254
551
  }