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.
- package/.agents/skills/euthyna/SKILL.md +256 -0
- package/.agents/skills/euthyna/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna/references/change-audit.md +219 -0
- package/.agents/skills/euthyna/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna/references/fact-producers.md +274 -0
- package/.agents/skills/euthyna/references/meta-mechanisms.md +173 -0
- package/.agents/skills/euthyna/references/verification-gates.md +293 -0
- package/LICENSE +202 -202
- package/README.md +65 -17
- package/cordis.patch.yml +4 -0
- package/package.json +23 -3
- package/plugin/index.js +60 -0
- package/src/cli.js +157 -6
- package/src/contract.js +2 -1
- package/src/facts/coverage.js +55 -2
- package/src/facts/deps.js +366 -0
- package/src/facts/history.js +322 -25
- package/src/gate.js +381 -0
package/src/facts/history.js
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|