workproof 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -144,6 +144,37 @@ only comparable when that line matches:
144
144
  How: `git blame --line-porcelain -w -M --ignore-revs-file .git-blame-ignore-revs HEAD -- <file> over a deterministic 1-in-1 file sample (surviving-lines 0.1.1: FNV-1a on path); generated, vendored and lock files excluded`
145
145
  ```
146
146
 
147
+ ## When you are two people
148
+
149
+ Git has no idea that the address on your laptop and the address on your work machine belong to
150
+ one person. It counts them as two authors, so a report about one of them describes half your
151
+ work and says nothing about the rest, and every figure in it is quietly too small. This is the
152
+ most common way these numbers go wrong and nothing normally tells you.
153
+
154
+ The report tells you:
155
+
156
+ ```
157
+ **1 other identity in this repository looks like the same person and was not counted here**:
158
+ Efe Genc. The same name, "Efe Genc", on another address. Every figure below therefore describes
159
+ only the work under the identities named above. If they are the same person, re-run with each
160
+ address passed to `--author`, or merge them in a `.mailmap`.
161
+ ```
162
+
163
+ The same line appears as a warning on stderr while the run is happening, and as
164
+ `identity.possiblySplit` in the JSON. Three signals raise it: the same name on another address,
165
+ a GitHub noreply login that matches your address or your name, and the same address name on
166
+ another domain. Role addresses like `dev@` and bot addresses are ignored, because sharing one
167
+ proves nothing.
168
+
169
+ It is a disclosure, not a correction. The tool cannot know whether two addresses are one person,
170
+ so it never merges them for you: pass each address to `--author`, or write a `.mailmap`, which
171
+ `git log`, `git blame` and `git shortlog` all read as well.
172
+ [`surviving-lines --identities`](https://github.com/Bubblegunn/surviving-lines) prints the
173
+ `.mailmap` lines for a whole repository if you want to fix it once for everyone.
174
+
175
+ The addresses themselves stay out of the document unless you pass `--emails`, the same rule the
176
+ rest of the report follows.
177
+
147
178
  ## In plain language
148
179
 
149
180
  Each repository section of the Markdown report opens with a paragraph a non-engineer can
@@ -461,6 +492,16 @@ workproof's only dependency. Degree of authorship follows Avelino, Hora and Vale
461
492
  (2016), major contributors follow Bird et al. (2011), and the absence factor is the CHAOSS
462
493
  Contributor Absence Factor.
463
494
 
495
+ ## Thanks
496
+
497
+ [@edwardsong08](https://github.com/edwardsong08) sent two pull requests on the day this was
498
+ published, without knowing me: `--format markdown|json|both`, so a pipeline can ask for one file
499
+ instead of two ([#9](https://github.com/Bubblegunn/workproof/pull/9)), and a language map that
500
+ covers Elixir, Scala, Haskell, Lua, R, Objective-C and Zig
501
+ ([#3](https://github.com/Bubblegunn/workproof/pull/3)). Both shipped in 0.1.3 and are in every
502
+ release since. The second one is why the footprint figure names the right language for anyone
503
+ outside the handful I had thought to list.
504
+
464
505
  ## Development
465
506
 
466
507
  ```
@@ -38,6 +38,12 @@ export interface RepoReport {
38
38
  emails: string[];
39
39
  names: string[];
40
40
  count: number;
41
+ /** Addresses here that look like the subject and were not counted as them. */
42
+ possiblySplit?: {
43
+ names: string[];
44
+ emails?: string[];
45
+ reasons: string[];
46
+ };
41
47
  };
42
48
  /** What the figures were computed with, so a verifier can tell a drift from an edit. */
43
49
  environment: {
@@ -8,7 +8,7 @@ import { isBot, excludedSet } from "./exclusions.js";
8
8
  // surviving-lines ships plain ESM JavaScript without type declarations.
9
9
  // @ts-ignore
10
10
  import { globToRegExp } from "surviving-lines/bin/surviving-lines.js";
11
- import { resolveIdentity } from "./figures/identity.js";
11
+ import { resolveIdentity, possibleSplits } from "./figures/identity.js";
12
12
  import { tenure, commitShare } from "./figures/commits.js";
13
13
  import { cadence } from "./figures/cadence.js";
14
14
  import { footprint, testsAndDocs } from "./figures/footprint.js";
@@ -83,6 +83,9 @@ export async function analyseRepo(cwd, params, hooks = {}) {
83
83
  const ex = await applyExclusions(cwd, everything, params);
84
84
  const all = ex.commits;
85
85
  const id = await resolveIdentity(all, params.author, cwd);
86
+ // Before any figure is computed: does someone else here look like the same person? A split
87
+ // identity makes every figure below describe half the work, silently.
88
+ const splits = possibleSplits(all, id);
86
89
  const t = tenure(all, id, { ...(params.since ? { since: params.since } : {}), ...(params.until ? { until: params.until } : {}) });
87
90
  const start = new Date(t.value.first + "T00:00:00Z");
88
91
  const end = new Date(t.value.last + "T23:59:59Z");
@@ -138,7 +141,20 @@ export async function analyseRepo(cwd, params, hooks = {}) {
138
141
  head: await headSha(cwd),
139
142
  fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd), key),
140
143
  fingerprintKeyed: true,
141
- identity: { emails: params.emails ? [...new Set(id.emails.map(publicEmail))] : [], names: id.names, count: id.emails.length },
144
+ identity: {
145
+ emails: params.emails ? [...new Set(id.emails.map(publicEmail))] : [],
146
+ names: id.names,
147
+ count: id.emails.length,
148
+ ...(splits.length
149
+ ? {
150
+ possiblySplit: {
151
+ names: [...new Set(splits.map((sp) => sp.name))],
152
+ ...(params.emails ? { emails: [...new Set(splits.map((sp) => publicEmail(sp.email)))] } : {}),
153
+ reasons: [...new Set(splits.map((sp) => sp.reason))],
154
+ },
155
+ }
156
+ : {}),
157
+ },
142
158
  environment: { git: await gitVersion(cwd), blame, ignoreRevs, seed: params.seed ?? "" },
143
159
  excluded: { botCommits: ex.botCommits, files: ex.excluded.size, linesAddedShare: addedAll ? addedExcluded / addedAll : 0, enabled: ex.enabled },
144
160
  figures,
package/dist/src/cli.js CHANGED
@@ -245,6 +245,12 @@ async function main() {
245
245
  await writeFile(`${out}.badge.json`, JSON.stringify(badgeFor(report), null, 2));
246
246
  written.push(`${out}.badge.json`);
247
247
  }
248
+ for (const repo of report.repositories) {
249
+ const split = repo.identity.possiblySplit;
250
+ if (!split)
251
+ continue;
252
+ progress(`${repo.name}: warning, ${split.names.length} other identity in this repository looks like the same person and is not in these figures (${split.names.join(", ")}). Pass every address to --author, or merge them in a .mailmap.`);
253
+ }
248
254
  console.log(`wrote ${written.join(" and ")} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
249
255
  }
250
256
  const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
@@ -22,3 +22,23 @@ export declare function foldIdentity(s: string): string;
22
22
  */
23
23
  export declare function resolveIdentity(commits: Commit[], author: string[] | undefined, cwd: string): Promise<Identity>;
24
24
  export declare const isMine: (c: Commit, id: Identity) => boolean;
25
+ /**
26
+ * Addresses in this repository that look like the subject but were not counted as them.
27
+ *
28
+ * A person who commits from a laptop and a work machine is two authors to git, so a report
29
+ * describes half of their work and says nothing about the other half. That is a silent wrong
30
+ * number in a tool whose whole claim is that its numbers can be checked, so the report
31
+ * discloses the suspicion rather than quietly resolving it: only the author knows whether two
32
+ * addresses are one person, and `--author` takes both when they are.
33
+ */
34
+ export declare function possibleSplits(commits: {
35
+ name: string;
36
+ email: string;
37
+ }[], identity: {
38
+ emails: string[];
39
+ names: string[];
40
+ }): {
41
+ name: string;
42
+ email: string;
43
+ reason: string;
44
+ }[];
@@ -54,3 +54,57 @@ function authorsHint(commits) {
54
54
  return top.length ? `Authors here: ${top.join(", ")}. Pass one with --author.` : "The repository has no commits.";
55
55
  }
56
56
  export const isMine = (c, id) => id.emails.includes(c.email);
57
+ /** Local parts that belong to a role rather than a person, so sharing one proves nothing. */
58
+ const GENERIC_LOCAL = new Set(["dev", "admin", "info", "me", "git", "hello", "mail", "noreply", "no-reply", "contact", "support", "team", "root", "user", "build", "ci", "bot", "test", "email", "work", "home"]);
59
+ const isBotAddress = (mail) => /\[bot\]@|^(?:dependabot|renovate|github-actions|greenkeeper)\b/i.test(mail);
60
+ /** The GitHub login inside `12345+login@users.noreply.github.com`, or the older form. */
61
+ const githubLogin = (mail) => {
62
+ const m = /^(?:\d+\+)?([^@+]+)@users\.noreply\.github\.com$/i.exec(mail);
63
+ return m ? m[1].toLowerCase() : null;
64
+ };
65
+ const localPart = (mail) => mail.split("@")[0].replace(/^\d+\+/, "");
66
+ const loose = (s) => foldIdentity(s).replace(/[\s._-]/g, "");
67
+ /**
68
+ * Addresses in this repository that look like the subject but were not counted as them.
69
+ *
70
+ * A person who commits from a laptop and a work machine is two authors to git, so a report
71
+ * describes half of their work and says nothing about the other half. That is a silent wrong
72
+ * number in a tool whose whole claim is that its numbers can be checked, so the report
73
+ * discloses the suspicion rather than quietly resolving it: only the author knows whether two
74
+ * addresses are one person, and `--author` takes both when they are.
75
+ */
76
+ export function possibleSplits(commits, identity) {
77
+ const mine = new Set(identity.emails.map(foldIdentity));
78
+ const myNames = identity.names.map(foldIdentity);
79
+ const myLogins = identity.emails.map(githubLogin).filter((l) => l !== null);
80
+ const myLocals = identity.emails.map(localPart);
81
+ const seen = new Set();
82
+ const out = [];
83
+ for (const c of commits) {
84
+ const email = c.email;
85
+ if (mine.has(foldIdentity(email)) || seen.has(foldIdentity(email)) || isBotAddress(email))
86
+ continue;
87
+ let reason = "";
88
+ if (myNames.includes(foldIdentity(c.name))) {
89
+ reason = `the same name, "${c.name}", on another address`;
90
+ }
91
+ else {
92
+ const theirLogin = githubLogin(email);
93
+ const theirLocal = localPart(email);
94
+ if (theirLogin && (myLocals.some((l) => loose(l) === loose(theirLogin)) || myNames.some((n) => loose(n) === loose(theirLogin)))) {
95
+ reason = `the GitHub login "${theirLogin}" matches yours`;
96
+ }
97
+ else if (myLogins.some((l) => loose(l) === loose(theirLocal) || myNames.some((n) => loose(n) === loose(theirLocal)))) {
98
+ reason = `the address name "${theirLocal}" matches your GitHub login`;
99
+ }
100
+ else if (!theirLogin && myLocals.some((l) => l === theirLocal && l.length >= 3 && !GENERIC_LOCAL.has(l))) {
101
+ reason = `the same address name, "${theirLocal}", on another domain`;
102
+ }
103
+ }
104
+ if (reason) {
105
+ seen.add(foldIdentity(email));
106
+ out.push({ name: c.name, email, reason });
107
+ }
108
+ }
109
+ return out;
110
+ }
@@ -72,7 +72,12 @@ export function renderMarkdown(report, narrative) {
72
72
  ``,
73
73
  ];
74
74
  for (const repo of report.repositories) {
75
- out.push(`## ${repo.name}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`, ``, repo.excluded.enabled
75
+ out.push(`## ${repo.name}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`, ``, ...(repo.identity.possiblySplit
76
+ ? [
77
+ `**${plural(repo.identity.possiblySplit.names.length, "other identity", "other identities")} in this repository look${repo.identity.possiblySplit.names.length === 1 ? "s" : ""} like the same person and ${repo.identity.possiblySplit.names.length === 1 ? "was" : "were"} not counted here**: ${repo.identity.possiblySplit.names.join(", ")}${repo.identity.possiblySplit.emails?.length ? ` (${repo.identity.possiblySplit.emails.join(", ")})` : ""}. ${repo.identity.possiblySplit.reasons.map((r, i) => (i === 0 ? r.charAt(0).toUpperCase() + r.slice(1) : r)).join("; ")}. Every figure below therefore describes only the work under the identities named above. If they are the same person, re-run with each address passed to \`--author\`, or merge them in a \`.mailmap\`.`,
78
+ ``,
79
+ ]
80
+ : []), repo.excluded.enabled
76
81
  ? `excluded ${plural(repo.excluded.botCommits, "bot commit", "bot commits")} and ${plural(repo.excluded.files, "generated, vendored or lock file", "generated, vendored or lock files")} (${pct(repo.excluded.linesAddedShare)} of lines added)`
77
82
  : `exclusions off (--no-exclusions): bot commits and generated files are counted`, ``, `### In plain language`, ``, plainSummary(repo), ``, `That paragraph is assembled from the figures below by a fixed rule, with no model involved, so it says nothing the numbers do not.`, ``);
78
83
  for (const f of repo.figures) {
@@ -31,6 +31,13 @@ function checkRepository(problems, path, r) {
31
31
  expect(problems, `${path}.identity.emails`, Array.isArray(id.emails), "array");
32
32
  expect(problems, `${path}.identity.names`, Array.isArray(id.names), "array");
33
33
  expect(problems, `${path}.identity.count`, typeof id.count === "number", "number");
34
+ if (id.possiblySplit !== undefined && expect(problems, `${path}.identity.possiblySplit`, isObject(id.possiblySplit), "object")) {
35
+ const ps = id.possiblySplit;
36
+ expect(problems, `${path}.identity.possiblySplit.names`, Array.isArray(ps.names), "array");
37
+ expect(problems, `${path}.identity.possiblySplit.reasons`, Array.isArray(ps.reasons), "array");
38
+ if (ps.emails !== undefined)
39
+ expect(problems, `${path}.identity.possiblySplit.emails`, Array.isArray(ps.emails), "array");
40
+ }
34
41
  }
35
42
  if (expect(problems, `${path}.environment`, isObject(repo.environment), "object")) {
36
43
  const env = repo.environment;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workproof",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Turn a private git repository into a verifiable engineering report for one author, without showing any code: thirteen figures from git, a hash anyone can recompute offline, verify, and an in-toto attestation.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "build": "tsc -p tsconfig.json",
26
26
  "lint": "tsc -p tsconfig.json --noEmit",
27
- "test": "npm run build && node --test dist/test/figures.test.js dist/test/exclusions.test.js dist/test/adversarial.test.js dist/test/integrity.test.js dist/test/attest.test.js dist/test/report.test.js dist/test/summary.test.js dist/test/cli.test.js dist/test/action.test.js test/release.test.mjs",
27
+ "test": "npm run build && node --test dist/test/figures.test.js dist/test/exclusions.test.js dist/test/adversarial.test.js dist/test/integrity.test.js dist/test/attest.test.js dist/test/report.test.js dist/test/summary.test.js dist/test/cli.test.js dist/test/action.test.js test/release.test.mjs test/suite.test.mjs",
28
28
  "prepublishOnly": "npm test",
29
29
  "examples": "npm run build && node examples/basic.mjs",
30
30
  "release": "node scripts/release.mjs",
@@ -4,52 +4,169 @@
4
4
  "title": "workproof report",
5
5
  "description": "A verifiable engineering report for one author. hash is sha256 over the RFC 8785 canonical JSON of { params, repositories }.",
6
6
  "type": "object",
7
- "required": ["tool", "schemaVersion", "version", "generatedAt", "params", "repositories", "hash"],
7
+ "required": [
8
+ "tool",
9
+ "schemaVersion",
10
+ "version",
11
+ "generatedAt",
12
+ "params",
13
+ "repositories",
14
+ "hash"
15
+ ],
8
16
  "properties": {
9
- "tool": { "const": "workproof" },
10
- "schemaVersion": { "const": 2 },
11
- "version": { "type": "string" },
12
- "generatedAt": { "type": "string", "format": "date-time" },
13
- "params": { "type": "object" },
14
- "hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
17
+ "tool": {
18
+ "const": "workproof"
19
+ },
20
+ "schemaVersion": {
21
+ "const": 2
22
+ },
23
+ "version": {
24
+ "type": "string"
25
+ },
26
+ "generatedAt": {
27
+ "type": "string",
28
+ "format": "date-time"
29
+ },
30
+ "params": {
31
+ "type": "object"
32
+ },
33
+ "hash": {
34
+ "type": "string",
35
+ "pattern": "^[0-9a-f]{64}$"
36
+ },
15
37
  "repositories": {
16
38
  "type": "array",
17
39
  "minItems": 1,
18
40
  "items": {
19
41
  "type": "object",
20
- "required": ["name", "head", "fingerprint", "identity", "environment", "excluded", "figures"],
42
+ "required": [
43
+ "name",
44
+ "head",
45
+ "fingerprint",
46
+ "identity",
47
+ "environment",
48
+ "excluded",
49
+ "figures"
50
+ ],
21
51
  "properties": {
22
- "name": { "type": "string" },
23
- "head": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
24
- "fingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
25
- "fingerprintKeyed": { "type": "boolean" },
52
+ "name": {
53
+ "type": "string"
54
+ },
55
+ "head": {
56
+ "type": "string",
57
+ "pattern": "^[0-9a-f]{40}$"
58
+ },
59
+ "fingerprint": {
60
+ "type": "string",
61
+ "pattern": "^[0-9a-f]{64}$"
62
+ },
63
+ "fingerprintKeyed": {
64
+ "type": "boolean"
65
+ },
26
66
  "identity": {
27
67
  "type": "object",
28
- "required": ["emails", "names", "count"],
68
+ "required": [
69
+ "emails",
70
+ "names",
71
+ "count"
72
+ ],
29
73
  "properties": {
30
- "emails": { "type": "array", "items": { "type": "string" } },
31
- "names": { "type": "array", "items": { "type": "string" } },
32
- "count": { "type": "integer", "minimum": 0 }
74
+ "emails": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "string"
78
+ }
79
+ },
80
+ "names": {
81
+ "type": "array",
82
+ "items": {
83
+ "type": "string"
84
+ }
85
+ },
86
+ "count": {
87
+ "type": "integer",
88
+ "minimum": 0
89
+ },
90
+ "possiblySplit": {
91
+ "type": "object",
92
+ "properties": {
93
+ "names": {
94
+ "type": "array",
95
+ "items": {
96
+ "type": "string"
97
+ }
98
+ },
99
+ "emails": {
100
+ "type": "array",
101
+ "items": {
102
+ "type": "string"
103
+ }
104
+ },
105
+ "reasons": {
106
+ "type": "array",
107
+ "items": {
108
+ "type": "string"
109
+ }
110
+ }
111
+ },
112
+ "required": [
113
+ "names",
114
+ "reasons"
115
+ ]
116
+ }
33
117
  }
34
118
  },
35
119
  "environment": {
36
120
  "type": "object",
37
- "required": ["git", "blame"],
121
+ "required": [
122
+ "git",
123
+ "blame"
124
+ ],
38
125
  "properties": {
39
- "git": { "type": "string" },
40
- "blame": { "type": "array", "items": { "type": "string" } },
41
- "ignoreRevs": { "type": ["string", "null"] },
42
- "seed": { "type": "string" }
126
+ "git": {
127
+ "type": "string"
128
+ },
129
+ "blame": {
130
+ "type": "array",
131
+ "items": {
132
+ "type": "string"
133
+ }
134
+ },
135
+ "ignoreRevs": {
136
+ "type": [
137
+ "string",
138
+ "null"
139
+ ]
140
+ },
141
+ "seed": {
142
+ "type": "string"
143
+ }
43
144
  }
44
145
  },
45
146
  "excluded": {
46
147
  "type": "object",
47
- "required": ["botCommits", "files", "linesAddedShare"],
148
+ "required": [
149
+ "botCommits",
150
+ "files",
151
+ "linesAddedShare"
152
+ ],
48
153
  "properties": {
49
- "botCommits": { "type": "integer", "minimum": 0 },
50
- "files": { "type": "integer", "minimum": 0 },
51
- "linesAddedShare": { "type": "number", "minimum": 0, "maximum": 1 },
52
- "enabled": { "type": "boolean" }
154
+ "botCommits": {
155
+ "type": "integer",
156
+ "minimum": 0
157
+ },
158
+ "files": {
159
+ "type": "integer",
160
+ "minimum": 0
161
+ },
162
+ "linesAddedShare": {
163
+ "type": "number",
164
+ "minimum": 0,
165
+ "maximum": 1
166
+ },
167
+ "enabled": {
168
+ "type": "boolean"
169
+ }
53
170
  }
54
171
  },
55
172
  "figures": {
@@ -57,13 +174,31 @@
57
174
  "minItems": 1,
58
175
  "items": {
59
176
  "type": "object",
60
- "required": ["id", "title", "value", "command", "limits"],
177
+ "required": [
178
+ "id",
179
+ "title",
180
+ "value",
181
+ "command",
182
+ "limits"
183
+ ],
61
184
  "properties": {
62
- "id": { "type": "string", "minLength": 1 },
63
- "title": { "type": "string" },
185
+ "id": {
186
+ "type": "string",
187
+ "minLength": 1
188
+ },
189
+ "title": {
190
+ "type": "string"
191
+ },
64
192
  "value": {},
65
- "command": { "type": "string" },
66
- "limits": { "type": "array", "items": { "type": "string" } }
193
+ "command": {
194
+ "type": "string"
195
+ },
196
+ "limits": {
197
+ "type": "array",
198
+ "items": {
199
+ "type": "string"
200
+ }
201
+ }
67
202
  }
68
203
  }
69
204
  }