workproof 0.1.3 → 0.3.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.
@@ -0,0 +1,53 @@
1
+ import type { Commit } from "../git.js";
2
+ import type { Figure, Identity } from "./types.js";
3
+ /** Avelino et al. 2016, "A novel approach for estimating truck factors": degree of authorship. */
4
+ export declare const degreeOfAuthorship: (fa: 0 | 1, dl: number, ac: number) => number;
5
+ export declare const DOA_MIN = 3.293;
6
+ export declare const DOA_NORMALISED_MIN = 0.75;
7
+ /**
8
+ * Files alive at HEAD whose degree of authorship for the subject is at least 0.75 of the
9
+ * file's maximum and at least 3.293 in absolute terms. Renames carry their history along.
10
+ */
11
+ export declare function filesAuthored(commits: Commit[], id: Identity, headFiles: Set<string>): Figure<{
12
+ authored: number;
13
+ total: number;
14
+ share: number;
15
+ }>;
16
+ export declare const MAJOR_THRESHOLD = 0.05;
17
+ /** Bird et al. 2011, "Don't touch my code": directories where the subject's commit share is at least 5%. */
18
+ export declare function majorContributor(commits: Commit[], id: Identity, depth: number): Figure<{
19
+ major: number;
20
+ dirs: number;
21
+ threshold: number;
22
+ }>;
23
+ export declare const HUGE_COMMIT = 10000;
24
+ /** Added plus deleted lines per non-merge commit by the subject, after exclusions. */
25
+ export declare function commitSize(commits: Commit[], id: Identity): Figure<{
26
+ median: number;
27
+ p90: number;
28
+ huge: number;
29
+ }>;
30
+ /** Commits by someone else that name the subject in a Co-authored-by trailer. */
31
+ export declare function coAuthored(commits: Commit[], id: Identity): Figure<{
32
+ trailerCommits: number;
33
+ }>;
34
+ /** CHAOSS Contributor Absence Factor: the smallest set of authors covering half the commits. */
35
+ export declare function absenceFactor(commits: Commit[], id: Identity): Figure<{
36
+ authorsToHalf: number;
37
+ authorRank: number;
38
+ authors: number;
39
+ }>;
40
+ export declare const AI_TOOLS: RegExp;
41
+ /** Subject commits whose trailers or author name declare an AI tool. */
42
+ export declare function aiAssisted(commits: Commit[], id: Identity): Figure<{
43
+ commits: number;
44
+ share: number;
45
+ }>;
46
+ /** The subject's surviving lines by the year of the commit that last touched them, from the same blame pass. */
47
+ export declare function survivalByCohort(byYear: {
48
+ year: number;
49
+ lines: number;
50
+ }[], sample: number): Figure<{
51
+ year: number;
52
+ lines: number;
53
+ }[]>;
@@ -0,0 +1,190 @@
1
+ import { isMine } from "./identity.js";
2
+ /**
3
+ * Figures about who owns what, each with its bias stated in limits. Bots and excluded
4
+ * files have already left the commit lists these functions receive.
5
+ */
6
+ const nonMerge = (commits) => commits.filter((c) => c.parents <= 1);
7
+ const oldestFirst = (commits) => [...commits].sort((a, b) => a.date.getTime() - b.date.getTime());
8
+ /** Avelino et al. 2016, "A novel approach for estimating truck factors": degree of authorship. */
9
+ export const degreeOfAuthorship = (fa, dl, ac) => 3.293 + 1.098 * fa + 0.164 * dl - 0.321 * Math.log(1 + ac);
10
+ export const DOA_MIN = 3.293;
11
+ export const DOA_NORMALISED_MIN = 0.75;
12
+ /**
13
+ * Files alive at HEAD whose degree of authorship for the subject is at least 0.75 of the
14
+ * file's maximum and at least 3.293 in absolute terms. Renames carry their history along.
15
+ */
16
+ export function filesAuthored(commits, id, headFiles) {
17
+ // path -> author email -> contribution; bots and excluded paths are already gone.
18
+ const files = new Map();
19
+ for (const c of oldestFirst(nonMerge(commits))) {
20
+ for (const f of c.files) {
21
+ let authors = files.get(f.path);
22
+ if (f.from && files.has(f.from) && !authors) {
23
+ authors = new Map([...files.get(f.from)].map(([email, v]) => [email, { ...v }]));
24
+ files.set(f.path, authors);
25
+ }
26
+ if (!authors) {
27
+ authors = new Map();
28
+ files.set(f.path, authors);
29
+ }
30
+ const entry = authors.get(c.email) ?? { first: authors.size === 0 && [...authors.values()].every((v) => !v.first), later: 0 };
31
+ if (authors.has(c.email))
32
+ entry.later++;
33
+ authors.set(c.email, entry);
34
+ }
35
+ }
36
+ let authored = 0;
37
+ for (const path of headFiles) {
38
+ const authors = files.get(path);
39
+ if (!authors)
40
+ continue;
41
+ const total = [...authors.values()].reduce((s, v) => s + (v.first ? 1 : 0) + v.later, 0);
42
+ const doaOf = (email) => {
43
+ const v = authors.get(email);
44
+ if (!v)
45
+ return 0;
46
+ const own = (v.first ? 1 : 0) + v.later;
47
+ return degreeOfAuthorship(v.first ? 1 : 0, v.later, total - own);
48
+ };
49
+ const max = Math.max(...[...authors.keys()].map(doaOf));
50
+ const mine = Math.max(...id.emails.map(doaOf));
51
+ if (mine >= DOA_MIN && max > 0 && mine / max > DOA_NORMALISED_MIN)
52
+ authored++;
53
+ }
54
+ const value = { authored, total: headFiles.size, share: headFiles.size ? authored / headFiles.size : 0 };
55
+ const figure = {
56
+ id: "filesAuthored",
57
+ title: "Files authored",
58
+ value,
59
+ command: "git log --no-merges --numstat -M over the history read; DOA = 3.293 + 1.098*FA + 0.164*DL - 0.321*ln(1 + AC) per file and author (Avelino et al.); authored when DOA is at least 3.293 and above 75% of the file's maximum; over files alive at HEAD",
60
+ limits: [
61
+ "The coefficients were fitted on other systems; first authorship outweighs later rewrites, so a file rewritten from scratch by someone else can stay with its creator.",
62
+ "A file that was renamed carries its history only when git detected the rename.",
63
+ ],
64
+ };
65
+ return figure;
66
+ }
67
+ function dirAt(path, depth) {
68
+ const parts = path.split("/");
69
+ if (parts.length <= 1)
70
+ return null;
71
+ return parts.slice(0, Math.min(depth, parts.length - 1)).join("/");
72
+ }
73
+ export const MAJOR_THRESHOLD = 0.05;
74
+ /** Bird et al. 2011, "Don't touch my code": directories where the subject's commit share is at least 5%. */
75
+ export function majorContributor(commits, id, depth) {
76
+ const dirs = new Map();
77
+ for (const c of nonMerge(commits)) {
78
+ const seen = new Set();
79
+ for (const f of c.files) {
80
+ const d = dirAt(f.path, depth);
81
+ if (d)
82
+ seen.add(d);
83
+ }
84
+ for (const d of seen) {
85
+ const e = dirs.get(d) ?? { author: 0, total: 0 };
86
+ e.total++;
87
+ if (isMine(c, id))
88
+ e.author++;
89
+ dirs.set(d, e);
90
+ }
91
+ }
92
+ const major = [...dirs.values()].filter((e) => e.total && e.author / e.total >= MAJOR_THRESHOLD).length;
93
+ const value = { major, dirs: dirs.size, threshold: MAJOR_THRESHOLD };
94
+ const figure = {
95
+ id: "majorContributor",
96
+ title: "Major-contributor components",
97
+ value,
98
+ command: `git log --no-merges --numstat; directories at depth ${depth} where the author's share of commits touching them is at least ${MAJOR_THRESHOLD * 100}% (Bird et al.)`,
99
+ limits: ["Commit exposure treats a typo and a subsystem alike; five percent of the commits to a directory is a low bar by design."],
100
+ };
101
+ return figure;
102
+ }
103
+ const rank = (sorted, p) => (sorted.length ? sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)] : 0);
104
+ export const HUGE_COMMIT = 10000;
105
+ /** Added plus deleted lines per non-merge commit by the subject, after exclusions. */
106
+ export function commitSize(commits, id) {
107
+ const sizes = nonMerge(commits)
108
+ .filter((c) => isMine(c, id))
109
+ .map((c) => c.files.reduce((s, f) => s + (f.added ?? 0) + (f.deleted ?? 0), 0))
110
+ .sort((a, b) => a - b);
111
+ const value = { median: rank(sizes, 0.5), p90: rank(sizes, 0.9), huge: sizes.filter((s) => s > HUGE_COMMIT).length };
112
+ const figure = {
113
+ id: "commitSize",
114
+ title: "Commit size",
115
+ value,
116
+ command: `git log --no-merges --numstat --author=<identity>; added plus deleted lines per commit over included files; median and 90th percentile by nearest rank; huge counts commits over ${HUGE_COMMIT.toLocaleString("en-US")} lines`,
117
+ limits: ["Size is not value. Imports, vendoring that slipped past the exclusions and reformat commits dominate the 90th percentile."],
118
+ };
119
+ return figure;
120
+ }
121
+ /** Commits by someone else that name the subject in a Co-authored-by trailer. */
122
+ export function coAuthored(commits, id) {
123
+ const trailerCommits = nonMerge(commits).filter((c) => !isMine(c, id) && c.coAuthors.some((e) => id.emails.includes(e))).length;
124
+ const value = { trailerCommits };
125
+ const figure = {
126
+ id: "coAuthored",
127
+ title: "Co-authored commits",
128
+ value,
129
+ command: "git log --no-merges --format=%(trailers:key=Co-authored-by); commits by another author whose trailer names one of the subject's emails",
130
+ limits: ["Trailers are written by whoever merges and can be absent or wrong; pairing without a trailer is invisible."],
131
+ };
132
+ return figure;
133
+ }
134
+ /** CHAOSS Contributor Absence Factor: the smallest set of authors covering half the commits. */
135
+ export function absenceFactor(commits, id) {
136
+ const counts = new Map();
137
+ for (const c of nonMerge(commits)) {
138
+ const key = isMine(c, id) ? "\0subject" : c.email;
139
+ counts.set(key, (counts.get(key) ?? 0) + 1);
140
+ }
141
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
142
+ const total = sorted.reduce((s, [, n]) => s + n, 0);
143
+ let covered = 0;
144
+ let authorsToHalf = 0;
145
+ for (const [, n] of sorted) {
146
+ if (covered >= total / 2)
147
+ break;
148
+ covered += n;
149
+ authorsToHalf++;
150
+ }
151
+ const value = { authorsToHalf, authorRank: sorted.findIndex(([k]) => k === "\0subject") + 1, authors: sorted.length };
152
+ const figure = {
153
+ id: "absenceFactor",
154
+ title: "Absence factor",
155
+ value,
156
+ command: "git log --no-merges --format=%aE; authors sorted by commit count; the smallest set covering 50% of commits (CHAOSS Contributor Absence Factor); the subject's rank by commit count",
157
+ limits: ["An author with several unmerged email addresses counts as several people; drive-by commits count as authors."],
158
+ };
159
+ return figure;
160
+ }
161
+ export const AI_TOOLS = /claude|cursor|copilot|codex|gemini|chatgpt|aider|devin|windsurf/i;
162
+ /** Subject commits whose trailers or author name declare an AI tool. */
163
+ export function aiAssisted(commits, id) {
164
+ const mine = nonMerge(commits).filter((c) => isMine(c, id));
165
+ const assisted = mine.filter((c) => c.coAuthorNames.some((n) => AI_TOOLS.test(n)) || c.assistedBy.some((a) => AI_TOOLS.test(a)) || /\(aider\)$/i.test(c.name)).length;
166
+ const value = { commits: assisted, share: mine.length ? assisted / mine.length : 0 };
167
+ const figure = {
168
+ id: "aiAssisted",
169
+ title: "AI-assisted commits",
170
+ value,
171
+ command: "git log --no-merges --format=%aN%(trailers:key=Co-authored-by)%(trailers:key=Assisted-by) --author=<identity>; commits naming Claude, Cursor, Copilot, Codex, Gemini, ChatGPT, Aider, Devin or Windsurf in a trailer, or an author name ending in (aider)",
172
+ limits: [
173
+ "The absence of a trailer is not evidence of unassisted work.",
174
+ "Blame credits the human for every line, so surviving lines say nothing about who typed them; authorship no longer implies comprehension.",
175
+ "These commits are never excluded from any other figure.",
176
+ ],
177
+ };
178
+ return figure;
179
+ }
180
+ /** The subject's surviving lines by the year of the commit that last touched them, from the same blame pass. */
181
+ export function survivalByCohort(byYear, sample) {
182
+ const figure = {
183
+ id: "survivalByCohort",
184
+ title: "Survival by cohort",
185
+ value: byYear,
186
+ command: `the same git blame --line-porcelain pass as the surviving-lines figure (1-in-${sample} sample); the subject's lines bucketed by author-time year`,
187
+ limits: ["Newer cohorts have had less time to die; comments and licence headers survive indefinitely."],
188
+ };
189
+ return figure;
190
+ }
@@ -24,7 +24,7 @@ export function cadence(commits, tags, id, window) {
24
24
  const mine = commits.filter((c) => isMine(c, id) && c.parents <= 1);
25
25
  const perWeek = new Map();
26
26
  for (const c of mine)
27
- perWeek.set(isoWeek(c.date), (perWeek.get(isoWeek(c.date)) ?? 0) + 1);
27
+ perWeek.set(isoWeek(c.localDate), (perWeek.get(isoWeek(c.localDate)) ?? 0) + 1);
28
28
  const weeks = weeksBetween(window.first, window.last);
29
29
  let streak = 0;
30
30
  let longest = 0;
@@ -52,8 +52,12 @@ export function cadence(commits, tags, id, window) {
52
52
  id: "cadence",
53
53
  title: "Cadence",
54
54
  value,
55
- command: "git log --no-merges --format=%aI --author=<identity>; ISO weeks; git for-each-ref refs/tags with creatordate",
56
- limits: ["A week with one commit and a week with forty both count as active.", "Tags are releases only if the project tags releases."],
55
+ command: "git log --no-merges --format=%aI --author=<identity>; ISO weeks in the author's own offset; git for-each-ref refs/tags with creatordate",
56
+ limits: [
57
+ "A week with one commit and a week with forty both count as active.",
58
+ "Tags are releases only if the project tags releases.",
59
+ "Weeks are ISO weeks read in the offset each commit records, so they are the author's weeks; a commit made Monday morning in Auckland counts as Monday, not as the previous week.",
60
+ ],
57
61
  };
58
62
  return figure;
59
63
  }
@@ -1,7 +1,7 @@
1
1
  import { isMine } from "./identity.js";
2
2
  const day = (d) => d.toISOString().slice(0, 10);
3
3
  export function tenure(commits, id, override) {
4
- const mine = commits.filter((c) => isMine(c, id)).map((c) => c.date).sort((a, b) => a.getTime() - b.getTime());
4
+ const mine = commits.filter((c) => isMine(c, id)).map((c) => c.localDate).sort((a, b) => a.getTime() - b.getTime());
5
5
  const first = override.since ? new Date(override.since) : mine[0];
6
6
  const last = override.until ? new Date(override.until) : mine[mine.length - 1];
7
7
  const days = Math.round((last.getTime() - first.getTime()) / 86400000) + 1;
@@ -10,7 +10,10 @@ export function tenure(commits, id, override) {
10
10
  title: "Tenure window",
11
11
  value: { first: day(first), last: day(last), days },
12
12
  command: override.since || override.until ? "--since/--until as given" : "git log --format=%aI --author=<identity>; first and last commit dates",
13
- limits: ["Tenure is measured from commits, so work before the first commit or after the last is invisible."],
13
+ limits: [
14
+ "Tenure is measured from commits, so work before the first commit or after the last is invisible.",
15
+ "The first and last days are read in the offset each commit records, so they are the author's calendar days rather than UTC days.",
16
+ ],
14
17
  };
15
18
  }
16
19
  export function commitShare(commits, id) {
@@ -20,9 +20,13 @@ export declare function footprint(commits: Commit[], id: Identity, opts: {
20
20
  share: number;
21
21
  }[];
22
22
  }>;
23
+ /**
24
+ * Test-file changes by the author over all, and documents the author created: Markdown,
25
+ * MDX and RST files whose oldest commit in the history read is the author's.
26
+ */
23
27
  export declare function testsAndDocs(commits: Commit[], id: Identity): Figure<{
24
28
  testChangesAuthor: number;
25
29
  testChangesTotal: number;
26
30
  testShare: number;
27
- docsAuthored: number;
31
+ docsCreated: number;
28
32
  }>;
@@ -61,18 +61,22 @@ export function footprint(commits, id, opts) {
61
61
  value,
62
62
  command: `git log --no-merges --numstat -M; directories at depth ${opts.depth} where the author's commit share is at least ${Math.round(opts.threshold * 100)}% over at least ${opts.minCommits} commits; languages by lines added, extension map`,
63
63
  limits: [
64
- "Lines added include generated and vendored files unless they were excluded upstream.",
64
+ "Generated, vendored, lock and snapshot files are excluded by the built-in lists and .gitattributes; anything they miss still counts.",
65
65
  "A directory owned by commit count may still contain other people's surviving code; see the blame figure.",
66
66
  ],
67
67
  };
68
68
  return figure;
69
69
  }
70
+ /**
71
+ * Test-file changes by the author over all, and documents the author created: Markdown,
72
+ * MDX and RST files whose oldest commit in the history read is the author's.
73
+ */
70
74
  export function testsAndDocs(commits, id) {
71
75
  const nonMerge = commits.filter((c) => c.parents <= 1);
72
76
  let testChangesAuthor = 0;
73
77
  let testChangesTotal = 0;
74
- const docs = new Set();
75
- for (const c of nonMerge) {
78
+ const firstTouch = new Map();
79
+ for (const c of [...nonMerge].sort((a, b) => a.date.getTime() - b.date.getTime())) {
76
80
  const mine = isMine(c, id);
77
81
  for (const f of c.files) {
78
82
  if (isTest(f.path)) {
@@ -80,17 +84,18 @@ export function testsAndDocs(commits, id) {
80
84
  if (mine)
81
85
  testChangesAuthor++;
82
86
  }
83
- if (mine && isDoc(f.path))
84
- docs.add(f.path);
87
+ if (isDoc(f.path) && !firstTouch.has(f.path))
88
+ firstTouch.set(f.path, mine);
85
89
  }
86
90
  }
87
- const value = { testChangesAuthor, testChangesTotal, testShare: testChangesTotal ? testChangesAuthor / testChangesTotal : 0, docsAuthored: docs.size };
91
+ const docsCreated = [...firstTouch.values()].filter(Boolean).length;
92
+ const value = { testChangesAuthor, testChangesTotal, testShare: testChangesTotal ? testChangesAuthor / testChangesTotal : 0, docsCreated };
88
93
  const figure = {
89
94
  id: "testsAndDocs",
90
- title: "Tests and documentation",
95
+ title: "Test-file changes and documents created",
91
96
  value,
92
- command: "git log --no-merges --numstat; test paths match __tests__/, test/, tests/, spec/, e2e/ or *.test.* / *.spec.*; docs are .md, .mdx, .rst",
93
- limits: ["Test file changes are counted, not test cases or coverage.", "A README edit and a design document count the same."],
97
+ command: "git log --no-merges --numstat; test paths match __tests__/, test/, tests/, spec/, e2e/ or *.test.* / *.spec.*; a document (.md, .mdx, .rst) is created by whoever made its oldest commit in the history read",
98
+ limits: ["Test-file changes are counted, not test cases or coverage.", "A one-line README and a design document count the same; a document rewritten by someone else stays with its creator."],
94
99
  };
95
100
  return figure;
96
101
  }
@@ -1,5 +1,20 @@
1
1
  import type { Commit } from "../git.js";
2
2
  import type { Identity } from "./types.js";
3
+ /**
4
+ * Fold a name or address for matching. Two things defeat a plain `toLowerCase()`:
5
+ *
6
+ * Normalization. A repository authored on Linux can carry "José" decomposed while the same name
7
+ * typed on macOS arrives precomposed. They are the same name and compared unequal, and the error
8
+ * then listed an author that looked identical to what was typed, which nobody can debug.
9
+ *
10
+ * The Turkish dotted and dotless i. `"İ".toLowerCase()` is "i" followed by a combining dot above,
11
+ * and `"I".toLowerCase()` is "i" rather than "ı", so "İSMAİL YILMAZ" matched neither "İsmail
12
+ * Yılmaz" nor itself lowercased. A locale-aware fold cannot be applied globally, because it would
13
+ * turn English "I" into "ı", so the four i forms collapse to one instead. The cost is that two
14
+ * names differing only in dotted and dotless i match each other; the benefit is that a Turkish
15
+ * name matches itself in any case, and the error message prints the folded form that was tried.
16
+ */
17
+ export declare function foldIdentity(s: string): string;
3
18
  /**
4
19
  * Resolve the author to report on. `author` entries match mailmapped emails or
5
20
  * names, case-insensitively; with none given, the repository's configured
@@ -1,4 +1,27 @@
1
1
  import { configuredEmail, configuredName } from "../git.js";
2
+ /**
3
+ * Fold a name or address for matching. Two things defeat a plain `toLowerCase()`:
4
+ *
5
+ * Normalization. A repository authored on Linux can carry "José" decomposed while the same name
6
+ * typed on macOS arrives precomposed. They are the same name and compared unequal, and the error
7
+ * then listed an author that looked identical to what was typed, which nobody can debug.
8
+ *
9
+ * The Turkish dotted and dotless i. `"İ".toLowerCase()` is "i" followed by a combining dot above,
10
+ * and `"I".toLowerCase()` is "i" rather than "ı", so "İSMAİL YILMAZ" matched neither "İsmail
11
+ * Yılmaz" nor itself lowercased. A locale-aware fold cannot be applied globally, because it would
12
+ * turn English "I" into "ı", so the four i forms collapse to one instead. The cost is that two
13
+ * names differing only in dotted and dotless i match each other; the benefit is that a Turkish
14
+ * name matches itself in any case, and the error message prints the folded form that was tried.
15
+ */
16
+ export function foldIdentity(s) {
17
+ return s
18
+ .normalize("NFC")
19
+ .replace(/[\u0130\u0131Ii]/g, "i")
20
+ .replace(/\u0307/g, "")
21
+ .toLowerCase()
22
+ .normalize("NFC")
23
+ .trim();
24
+ }
2
25
  /**
3
26
  * Resolve the author to report on. `author` entries match mailmapped emails or
4
27
  * names, case-insensitively; with none given, the repository's configured
@@ -7,19 +30,19 @@ import { configuredEmail, configuredName } from "../git.js";
7
30
  export async function resolveIdentity(commits, author, cwd) {
8
31
  const explicit = author && author.length ? author : [];
9
32
  // Without --author, try the configured email, then the configured name.
10
- const wanted = (explicit.length ? explicit : [await configuredEmail(cwd), await configuredName(cwd)]).map((a) => a.toLowerCase()).filter(Boolean);
33
+ const wanted = (explicit.length ? explicit : [await configuredEmail(cwd), await configuredName(cwd)]).filter(Boolean).map(foldIdentity).filter(Boolean);
11
34
  if (!wanted.length)
12
35
  throw new Error(`no author given and git config has no user.email or user.name; pass --author. ${authorsHint(commits)}`);
13
36
  const emails = new Set();
14
37
  const names = new Set();
15
38
  for (const c of commits) {
16
- if (wanted.includes(c.email) || wanted.includes(c.name.toLowerCase())) {
39
+ if (wanted.includes(foldIdentity(c.email)) || wanted.includes(foldIdentity(c.name))) {
17
40
  emails.add(c.email);
18
41
  names.add(c.name);
19
42
  }
20
43
  }
21
44
  if (!emails.size)
22
- throw new Error(`no commits by ${wanted.join(" or ")} in this repository. ${authorsHint(commits)}`);
45
+ throw new Error(`no commits by ${wanted.join(" or ")} in this repository, comparing names and addresses folded to that form. ${authorsHint(commits)}`);
23
46
  return { emails: [...emails].sort(), names: [...names].sort() };
24
47
  }
25
48
  /** The most frequent author names, so the error tells the user what to pass. */
@@ -1,12 +1,48 @@
1
1
  import type { Figure, Identity } from "./types.js";
2
- export declare function survivingLines(cwd: string, id: Identity, opts: {
2
+ export interface SurvivingOptions {
3
3
  sample: number;
4
+ seed: string;
5
+ /** Globs with surviving-lines semantics (a pattern without a slash also matches the basename). */
6
+ exclude: string[];
7
+ copies: boolean;
8
+ /** As recorded in the report: ".git-blame-ignore-revs" for the root file, or the path given. */
9
+ ignoreRevsFile: string | null;
10
+ /** Paths already dropped by the exclusion rules. */
11
+ excluded: Set<string>;
4
12
  version: string;
5
- }): Promise<Figure<{
13
+ jobs?: number;
14
+ }
15
+ /** The blame flags a report records under environment.blame, in the order git receives them. */
16
+ export declare function blameFlags(copies: boolean, ignoreRevsFile: string | null): string[];
17
+ /** The empty tree's id under this repository's hash algorithm, without touching /dev/null. */
18
+ export declare const emptyTreeId: (cwd: string) => Promise<string>;
19
+ /** Text files at HEAD with their line counts, in one git call; binaries come back as "-" and are skipped. */
20
+ export declare function listTextFiles(cwd: string): Promise<{
21
+ path: string;
22
+ lines: number;
23
+ }[]>;
24
+ interface BlameLine {
25
+ mail: string;
26
+ year: number;
27
+ }
28
+ /** One entry per surviving line: the author email and the year of the blamed commit. */
29
+ export declare function parseBlame(porcelain: string): BlameLine[];
30
+ /**
31
+ * One blame pass over a deterministic sample of the included text files. Every line is
32
+ * attributed once; the subject's lines are also bucketed by the year of the commit that
33
+ * last touched them, which is what survivalByCohort reports.
34
+ */
35
+ export declare function survivingLines(cwd: string, id: Identity, opts: SurvivingOptions): Promise<Figure<{
6
36
  lines: number;
7
37
  linesAttributed: number;
8
38
  share: number;
9
39
  filesSampled: number;
10
40
  filesTotal: number;
11
41
  sample: number;
42
+ seed: string;
43
+ byYear: {
44
+ year: number;
45
+ lines: number;
46
+ }[];
12
47
  }>>;
48
+ export {};
@@ -1,26 +1,97 @@
1
+ import { isAbsolute, join } from "node:path";
2
+ import { git } from "../git.js";
1
3
  // surviving-lines ships plain ESM JavaScript without type declarations.
2
4
  // @ts-ignore
3
- import { analyse, parseArgs } from "surviving-lines/bin/surviving-lines.js";
5
+ import { globToRegExp, inSample } from "surviving-lines/bin/surviving-lines.js";
6
+ /** The blame flags a report records under environment.blame, in the order git receives them. */
7
+ export function blameFlags(copies, ignoreRevsFile) {
8
+ return ["-w", "-M", ...(copies ? ["-C"] : []), ...(ignoreRevsFile ? [`--ignore-revs-file ${ignoreRevsFile}`] : [])];
9
+ }
10
+ /** The empty tree's id under this repository's hash algorithm, without touching /dev/null. */
11
+ export const emptyTreeId = async (cwd) => (await git(["hash-object", "-t", "tree", "--stdin"], cwd, "")).trim();
12
+ /** Text files at HEAD with their line counts, in one git call; binaries come back as "-" and are skipped. */
13
+ export async function listTextFiles(cwd) {
14
+ const out = await git(["diff", "--numstat", "-z", await emptyTreeId(cwd), "HEAD"], cwd);
15
+ const files = [];
16
+ for (const rec of out.split("\0")) {
17
+ if (!rec)
18
+ continue;
19
+ const [added, , path] = rec.split("\t");
20
+ if (added === "-" || path === undefined)
21
+ continue;
22
+ files.push({ path, lines: Number(added) });
23
+ }
24
+ return files;
25
+ }
26
+ /** One entry per surviving line: the author email and the year of the blamed commit. */
27
+ export function parseBlame(porcelain) {
28
+ const lines = [];
29
+ let mail = "";
30
+ let year = 0;
31
+ for (const line of porcelain.split("\n")) {
32
+ if (line.startsWith("author-mail "))
33
+ mail = line.slice(12).replace(/^<|>$/g, "").toLowerCase();
34
+ else if (line.startsWith("author-time "))
35
+ year = new Date(Number(line.slice(12)) * 1000).getUTCFullYear();
36
+ else if (line.startsWith("\t"))
37
+ lines.push({ mail, year });
38
+ }
39
+ return lines;
40
+ }
41
+ /**
42
+ * One blame pass over a deterministic sample of the included text files. Every line is
43
+ * attributed once; the subject's lines are also bucketed by the year of the commit that
44
+ * last touched them, which is what survivalByCohort reports.
45
+ */
4
46
  export async function survivingLines(cwd, id, opts) {
5
- const result = await analyse(parseArgs(["--cwd", cwd, "--sample", String(opts.sample)]));
6
- const mine = result.authors.filter((a) => id.emails.includes(a.mail));
7
- const lines = mine.reduce((s, a) => s + a.lines, 0);
47
+ const excludeRe = opts.exclude.map((g) => globToRegExp(g));
48
+ const excludeBare = opts.exclude.map((g) => !g.includes("/"));
49
+ const dropped = (path) => {
50
+ if (opts.excluded.has(path))
51
+ return true;
52
+ const base = path.slice(path.lastIndexOf("/") + 1);
53
+ return excludeRe.some((re, i) => re.test(path) || (excludeBare[i] && re.test(base)));
54
+ };
55
+ const all = (await listTextFiles(cwd)).filter((f) => !dropped(f.path));
56
+ const sampled = all.filter((f) => inSample(f.path, opts.sample, opts.seed));
57
+ const ignoreRevs = opts.ignoreRevsFile === null ? null : isAbsolute(opts.ignoreRevsFile) ? opts.ignoreRevsFile : join(cwd, opts.ignoreRevsFile);
58
+ const args = ["blame", "--line-porcelain", "-w", "-M", ...(opts.copies ? ["-C"] : []), ...(ignoreRevs ? ["--ignore-revs-file", ignoreRevs] : []), "HEAD", "--"];
59
+ let attributed = 0;
60
+ let mine = 0;
61
+ const byYear = new Map();
62
+ let cursor = 0;
63
+ const worker = async () => {
64
+ while (cursor < sampled.length) {
65
+ const file = sampled[cursor++];
66
+ for (const line of parseBlame(await git([...args, file.path], cwd))) {
67
+ attributed++;
68
+ if (id.emails.includes(line.mail)) {
69
+ mine++;
70
+ byYear.set(line.year, (byYear.get(line.year) ?? 0) + 1);
71
+ }
72
+ }
73
+ }
74
+ };
75
+ await Promise.all(Array.from({ length: Math.min(opts.jobs ?? 4, sampled.length || 1) }, worker));
8
76
  const value = {
9
- lines,
10
- linesAttributed: result.sample.linesAttributed,
11
- share: result.sample.linesAttributed ? lines / result.sample.linesAttributed : 0,
12
- filesSampled: result.sample.filesSampled,
13
- filesTotal: result.sample.filesTotal,
77
+ lines: mine,
78
+ linesAttributed: attributed,
79
+ share: attributed ? mine / attributed : 0,
80
+ filesSampled: sampled.length,
81
+ filesTotal: all.length,
14
82
  sample: opts.sample,
83
+ seed: opts.seed,
84
+ byYear: [...byYear.entries()].sort((a, b) => a[0] - b[0]).map(([year, lines]) => ({ year, lines })),
15
85
  };
16
86
  const figure = {
17
87
  id: "survivingLines",
18
88
  title: "Surviving lines at HEAD",
19
89
  value,
20
- command: `surviving-lines ${opts.version}: git blame -w -M --line-porcelain over a deterministic 1-in-${opts.sample} file sample (FNV-1a on path)`,
90
+ command: `git blame --line-porcelain ${blameFlags(opts.copies, opts.ignoreRevsFile).join(" ")} HEAD -- <file> over a deterministic 1-in-${opts.sample} file sample (surviving-lines ${opts.version}: FNV-1a on path${opts.seed ? ` with seed "${opts.seed}"` : ""}); generated, vendored and lock files excluded`,
21
91
  limits: [
22
92
  "Survivorship, not merit: code deleted on purpose counts for nobody.",
23
- "Whitespace and moved lines keep their original author; copied lines do not unless --copies is used upstream.",
93
+ "Whitespace and moved lines keep their original author; copied lines do not unless --copies is used.",
94
+ opts.ignoreRevsFile ? `Commits listed in ${opts.ignoreRevsFile} are skipped, so a reformat does not take the lines it touched.` : "A reformat commit takes every line it touched; list such commits in .git-blame-ignore-revs.",
24
95
  ],
25
96
  };
26
97
  return figure;