workproof 0.1.2 → 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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What leaves every denominator before a figure is computed.
3
+ *
4
+ * Bots are recognised by the two patterns GitHub uses for app identities, nothing
5
+ * cleverer: a name ending in "[bot]" or the "<id>+<name>[bot]@users.noreply.github.com"
6
+ * address. Generated, vendored, lock and snapshot files follow the subset of
7
+ * github-linguist's generated.rb and vendor.yml that moves line counts, plus whatever a
8
+ * repository marks with linguist-generated or linguist-vendored in .gitattributes.
9
+ */
10
+ export const isBot = (c) => /\[bot\]$/.test(c.name) || /^\d+\+.*\[bot\]@users\.noreply\.github\.com$/i.test(c.email);
11
+ const LOCK_FILES = new Set([
12
+ "package-lock.json",
13
+ "npm-shrinkwrap.json",
14
+ "yarn.lock",
15
+ "pnpm-lock.yaml",
16
+ "bun.lockb",
17
+ "bun.lock",
18
+ "cargo.lock",
19
+ "poetry.lock",
20
+ "uv.lock",
21
+ "pdm.lock",
22
+ "pipfile.lock",
23
+ "gemfile.lock",
24
+ "composer.lock",
25
+ "go.sum",
26
+ "packages.lock.json",
27
+ "flake.lock",
28
+ "mix.lock",
29
+ "pubspec.lock",
30
+ "gradle.lockfile",
31
+ "podfile.lock",
32
+ "deno.lock",
33
+ ]);
34
+ const VENDORED_DIRS = /(^|\/)(vendor|vendors|node_modules|third_party|third-party|thirdparty|bower_components|jspm_packages|pods|\.yarn|\.pnp|extern|externals|deps)\//i;
35
+ const GENERATED_PATTERNS = [
36
+ /(^|\/)__snapshots__\//,
37
+ /\.snap$/,
38
+ /\.min\.(js|css|mjs|cjs)$/,
39
+ /\.(js|css|mjs|cjs)\.map$/,
40
+ /\.pb\.(go|ts|js|cc|h|swift|rb|py)$/,
41
+ /_pb2(_grpc)?\.py$/,
42
+ /\.pb\.dart$/,
43
+ /\.g\.(dart|cs)$/,
44
+ /\.generated\.[a-z0-9]+$/i,
45
+ /\.designer\.cs$/i,
46
+ /(^|\/)generated\//i,
47
+ /(^|\/)__generated__\//,
48
+ /(^|\/)dist\//,
49
+ /(^|\/)build\/.*\.(js|css)$/,
50
+ /(^|\/)swagger\.(json|yaml|yml)$/i,
51
+ /(^|\/)openapi\.(json|yaml|yml)$/i,
52
+ /\.xcodeproj\//,
53
+ /\.nib$/,
54
+ /\.xib$/,
55
+ /\.storyboard$/,
56
+ /(^|\/)Godeps\//,
57
+ /(^|\/)Pipfile\.lock$/,
58
+ ];
59
+ /** True for paths the built-in lists treat as generated, vendored, lock or snapshot files. */
60
+ export function isExcludedPath(path) {
61
+ const base = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
62
+ if (LOCK_FILES.has(base))
63
+ return true;
64
+ if (VENDORED_DIRS.test(path))
65
+ return true;
66
+ return GENERATED_PATTERNS.some((re) => re.test(path));
67
+ }
68
+ /**
69
+ * The set of paths to drop, from the built-in lists, the repository's .gitattributes,
70
+ * and the user's --exclude globs (already compiled to RegExp).
71
+ */
72
+ export function excludedSet(paths, attrs, extra) {
73
+ const out = new Set();
74
+ for (const p of paths) {
75
+ const a = attrs.get(p);
76
+ if (isExcludedPath(p) || a?.generated || a?.vendored || extra.some((re) => re.test(p)))
77
+ out.add(p);
78
+ }
79
+ return out;
80
+ }
@@ -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
+ }
@@ -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
  }>;
@@ -2,7 +2,8 @@ import { isMine } from "./identity.js";
2
2
  const LANG = {
3
3
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
4
4
  py: "Python", cs: "C#", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", swift: "Swift", rb: "Ruby", php: "PHP",
5
- c: "C", h: "C", cpp: "C++", hpp: "C++", css: "CSS", scss: "CSS", html: "HTML", vue: "Vue", svelte: "Svelte",
5
+ c: "C", h: "C", cpp: "C++", hpp: "C++", ex: "Elixir", exs: "Elixir", scala: "Scala", sc: "Scala", hs: "Haskell", lhs: "Haskell", lua: "Lua", r: "R", m: "Objective-C", mm: "Objective-C", zig: "Zig",
6
+ css: "CSS", scss: "CSS", html: "HTML", vue: "Vue", svelte: "Svelte",
6
7
  sql: "SQL", sh: "Shell", yml: "YAML", yaml: "YAML", json: "JSON", md: "Markdown", mdx: "Markdown", tf: "Terraform", dart: "Dart",
7
8
  };
8
9
  export function languageOf(path) {
@@ -60,18 +61,22 @@ export function footprint(commits, id, opts) {
60
61
  value,
61
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`,
62
63
  limits: [
63
- "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.",
64
65
  "A directory owned by commit count may still contain other people's surviving code; see the blame figure.",
65
66
  ],
66
67
  };
67
68
  return figure;
68
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
+ */
69
74
  export function testsAndDocs(commits, id) {
70
75
  const nonMerge = commits.filter((c) => c.parents <= 1);
71
76
  let testChangesAuthor = 0;
72
77
  let testChangesTotal = 0;
73
- const docs = new Set();
74
- 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())) {
75
80
  const mine = isMine(c, id);
76
81
  for (const f of c.files) {
77
82
  if (isTest(f.path)) {
@@ -79,17 +84,18 @@ export function testsAndDocs(commits, id) {
79
84
  if (mine)
80
85
  testChangesAuthor++;
81
86
  }
82
- if (mine && isDoc(f.path))
83
- docs.add(f.path);
87
+ if (isDoc(f.path) && !firstTouch.has(f.path))
88
+ firstTouch.set(f.path, mine);
84
89
  }
85
90
  }
86
- 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 };
87
93
  const figure = {
88
94
  id: "testsAndDocs",
89
- title: "Tests and documentation",
95
+ title: "Test-file changes and documents created",
90
96
  value,
91
- command: "git log --no-merges --numstat; test paths match __tests__/, test/, tests/, spec/, e2e/ or *.test.* / *.spec.*; docs are .md, .mdx, .rst",
92
- 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."],
93
99
  };
94
100
  return figure;
95
101
  }
@@ -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;
package/dist/src/git.d.ts CHANGED
@@ -1,8 +1,17 @@
1
- export declare function git(args: string[], cwd: string): Promise<string>;
1
+ /**
2
+ * Settings that change what diff and blame attribute, pinned so two machines with different
3
+ * defaults produce the same figures. indentHeuristic became the default in git 2.14 and
4
+ * rename detection in 2.9; a report records the git version it ran under as well.
5
+ */
6
+ export declare const PINNED_CONFIG: string[];
7
+ export declare function git(args: string[], cwd: string, input?: string): Promise<string>;
8
+ export declare const gitVersion: (cwd: string) => Promise<string>;
2
9
  export interface FileChange {
3
10
  path: string;
4
11
  added: number | null;
5
12
  deleted: number | null;
13
+ /** The previous path when git detected a rename in this commit. */
14
+ from?: string;
6
15
  }
7
16
  export interface Commit {
8
17
  sha: string;
@@ -11,8 +20,14 @@ export interface Commit {
11
20
  date: Date;
12
21
  parents: number;
13
22
  files: FileChange[];
23
+ /** Lower-cased emails from Co-authored-by trailers. */
24
+ coAuthors: string[];
25
+ /** Names from Co-authored-by trailers, as written. */
26
+ coAuthorNames: string[];
27
+ /** Values of Assisted-by trailers, as written. */
28
+ assistedBy: string[];
14
29
  }
15
- /** All commits reachable from HEAD, newest first, with per-file numstat. One git call. */
30
+ /** All commits reachable from HEAD, newest first, with per-file numstat and trailers. One git call. */
16
31
  export declare function listCommits(cwd: string, opts: {
17
32
  since?: string;
18
33
  until?: string;
@@ -24,10 +39,17 @@ export interface Tag {
24
39
  email: string;
25
40
  }
26
41
  export declare function listTags(cwd: string): Promise<Tag[]>;
42
+ /** Every path in the HEAD tree. */
43
+ export declare function listHeadFiles(cwd: string): Promise<string[]>;
27
44
  export declare const rootCommit: (cwd: string) => Promise<string>;
28
45
  export declare const headSha: (cwd: string) => Promise<string>;
29
46
  export declare function remoteUrl(cwd: string): Promise<string>;
30
47
  export declare function configuredName(cwd: string): Promise<string>;
31
48
  /** Throws a plain sentence when the directory is not inside a git repository. */
32
49
  export declare function assertRepository(cwd: string): Promise<void>;
50
+ /** linguist-generated and linguist-vendored from .gitattributes, for the given paths. */
51
+ export declare function checkAttr(cwd: string, paths: string[]): Promise<Map<string, {
52
+ generated: boolean;
53
+ vendored: boolean;
54
+ }>>;
33
55
  export declare function configuredEmail(cwd: string): Promise<string>;