workproof 0.2.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 +42 -1
- package/dist/src/analyse.d.ts +6 -0
- package/dist/src/analyse.js +20 -4
- package/dist/src/cli.js +6 -0
- package/dist/src/figures/cadence.js +7 -3
- package/dist/src/figures/commits.js +5 -2
- package/dist/src/figures/identity.d.ts +35 -0
- package/dist/src/figures/identity.js +80 -3
- package/dist/src/git.d.ts +21 -0
- package/dist/src/git.js +24 -1
- package/dist/src/report.js +6 -1
- package/dist/src/schema.js +7 -0
- package/package.json +2 -2
- package/schema/report.schema.json +166 -31
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
|
|
@@ -172,7 +203,7 @@ snapshot files are removed before any of them is computed (see Gaming and bias).
|
|
|
172
203
|
|---|---|---|
|
|
173
204
|
| Tenure window | first to last commit by the author, or `--since/--until` | work before the first commit or after the last |
|
|
174
205
|
| Share of commits | non-merge commits by the author over all human non-merge commits in the window | what survived; a typo and a subsystem count the same |
|
|
175
|
-
| Cadence | active weeks, commits per active week, longest streak, release tags in tenure and the author's | a week with one commit and a week with forty both count as active |
|
|
206
|
+
| Cadence | active weeks, commits per active week, longest streak, release tags in tenure and the author's | a week with one commit and a week with forty both count as active; weeks are the author's own, read from the offset each commit records |
|
|
176
207
|
| Footprint | files touched, directories at or above a commit-share threshold, languages by lines added | anything the exclusion lists miss still counts |
|
|
177
208
|
| Test-file changes and documents created | share of test-file changes; `.md`, `.mdx`, `.rst` files whose oldest commit is the author's | test cases, coverage, or the quality of a document |
|
|
178
209
|
| Files authored | files alive at HEAD where the author's degree of authorship (Avelino et al.) is at least 3.293 and above 75% of the file's maximum | coefficients fitted on other systems; first authorship outweighs later rewrites |
|
|
@@ -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
|
```
|
package/dist/src/analyse.d.ts
CHANGED
|
@@ -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: {
|
package/dist/src/analyse.js
CHANGED
|
@@ -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,14 +83,17 @@ 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");
|
|
89
|
-
const inTenure = all.filter((c) => c.
|
|
92
|
+
const inTenure = all.filter((c) => c.localDate >= start && c.localDate <= end);
|
|
90
93
|
let addedAll = 0;
|
|
91
94
|
let addedExcluded = 0;
|
|
92
95
|
for (const c of ex.raw) {
|
|
93
|
-
if (c.
|
|
96
|
+
if (c.localDate < start || c.localDate > end || c.parents > 1)
|
|
94
97
|
continue;
|
|
95
98
|
for (const f of c.files) {
|
|
96
99
|
if (f.added === null)
|
|
@@ -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: {
|
|
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 : "";
|
|
@@ -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.
|
|
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: [
|
|
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.
|
|
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: [
|
|
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) {
|
|
@@ -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
|
|
@@ -7,3 +22,23 @@ import type { Identity } from "./types.js";
|
|
|
7
22
|
*/
|
|
8
23
|
export declare function resolveIdentity(commits: Commit[], author: string[] | undefined, cwd: string): Promise<Identity>;
|
|
9
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
|
+
}[];
|
|
@@ -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)]).
|
|
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
|
|
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. */
|
|
@@ -31,3 +54,57 @@ function authorsHint(commits) {
|
|
|
31
54
|
return top.length ? `Authors here: ${top.join(", ")}. Pass one with --author.` : "The repository has no commits.";
|
|
32
55
|
}
|
|
33
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
|
+
}
|
package/dist/src/git.d.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
* Settings that change what diff and blame attribute, pinned so two machines with different
|
|
3
3
|
* defaults produce the same figures. indentHeuristic became the default in git 2.14 and
|
|
4
4
|
* rename detection in 2.9; a report records the git version it ran under as well.
|
|
5
|
+
*
|
|
6
|
+
* `core.precomposeunicode` is true in every repository git creates on macOS, and it rewrites
|
|
7
|
+
* command-line arguments from decomposed to precomposed form. Paths here are read out of the
|
|
8
|
+
* tree and handed back as pathspecs, so a stored decomposed path came back as a different
|
|
9
|
+
* string and matched nothing: "fatal: no such path café.ts in HEAD", and the run ended with no
|
|
10
|
+
* report. Pinning it off keeps the path sent identical to the path git stored. A checkout
|
|
11
|
+
* authored on Linux carrying Korean, French, Turkish, Vietnamese, Portuguese or Spanish
|
|
12
|
+
* filenames was affected; ASCII repositories are unchanged either way.
|
|
5
13
|
*/
|
|
6
14
|
export declare const PINNED_CONFIG: string[];
|
|
7
15
|
export declare function git(args: string[], cwd: string, input?: string): Promise<string>;
|
|
@@ -18,6 +26,12 @@ export interface Commit {
|
|
|
18
26
|
email: string;
|
|
19
27
|
name: string;
|
|
20
28
|
date: Date;
|
|
29
|
+
/**
|
|
30
|
+
* The author's wall clock, as a Date whose UTC fields are the local calendar date and time
|
|
31
|
+
* that `%aI` reports. Calendar figures (which week, which day) must use this; anything
|
|
32
|
+
* ordering commits in time must use `date`, which is the instant.
|
|
33
|
+
*/
|
|
34
|
+
localDate: Date;
|
|
21
35
|
parents: number;
|
|
22
36
|
files: FileChange[];
|
|
23
37
|
/** Lower-cased emails from Co-authored-by trailers. */
|
|
@@ -33,6 +47,13 @@ export declare function listCommits(cwd: string, opts: {
|
|
|
33
47
|
until?: string;
|
|
34
48
|
max?: number;
|
|
35
49
|
}): Promise<Commit[]>;
|
|
50
|
+
/**
|
|
51
|
+
* `%aI` carries the author's offset, for example 2026-03-02T00:30:00+13:00. `new Date` keeps the
|
|
52
|
+
* instant and drops the offset, so a commit made on Monday morning in Auckland lands in the
|
|
53
|
+
* previous UTC week. This shifts the instant by the stated offset so the UTC getters read back
|
|
54
|
+
* the author's own calendar.
|
|
55
|
+
*/
|
|
56
|
+
export declare function wallClock(iso: string): Date;
|
|
36
57
|
export interface Tag {
|
|
37
58
|
name: string;
|
|
38
59
|
date: Date;
|
package/dist/src/git.js
CHANGED
|
@@ -3,8 +3,16 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
* Settings that change what diff and blame attribute, pinned so two machines with different
|
|
4
4
|
* defaults produce the same figures. indentHeuristic became the default in git 2.14 and
|
|
5
5
|
* rename detection in 2.9; a report records the git version it ran under as well.
|
|
6
|
+
*
|
|
7
|
+
* `core.precomposeunicode` is true in every repository git creates on macOS, and it rewrites
|
|
8
|
+
* command-line arguments from decomposed to precomposed form. Paths here are read out of the
|
|
9
|
+
* tree and handed back as pathspecs, so a stored decomposed path came back as a different
|
|
10
|
+
* string and matched nothing: "fatal: no such path café.ts in HEAD", and the run ended with no
|
|
11
|
+
* report. Pinning it off keeps the path sent identical to the path git stored. A checkout
|
|
12
|
+
* authored on Linux carrying Korean, French, Turkish, Vietnamese, Portuguese or Spanish
|
|
13
|
+
* filenames was affected; ASCII repositories are unchanged either way.
|
|
6
14
|
*/
|
|
7
|
-
export const PINNED_CONFIG = ["-c", "diff.renames=true", "-c", "diff.algorithm=myers", "-c", "diff.indentHeuristic=true", "-c", "core.autocrlf=false"];
|
|
15
|
+
export const PINNED_CONFIG = ["-c", "diff.renames=true", "-c", "diff.algorithm=myers", "-c", "diff.indentHeuristic=true", "-c", "core.autocrlf=false", "-c", "core.precomposeunicode=false"];
|
|
8
16
|
export function git(args, cwd, input) {
|
|
9
17
|
return new Promise((resolve, reject) => {
|
|
10
18
|
const child = execFile("git", [...PINNED_CONFIG, ...args], { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }, (err, stdout) => {
|
|
@@ -70,6 +78,7 @@ export async function listCommits(cwd, opts) {
|
|
|
70
78
|
email: email.toLowerCase(),
|
|
71
79
|
name: name,
|
|
72
80
|
date: new Date(iso),
|
|
81
|
+
localDate: wallClock(iso),
|
|
73
82
|
parents: parents ? parents.trim().split(" ").filter(Boolean).length : 0,
|
|
74
83
|
files,
|
|
75
84
|
coAuthors: coAuthorValues.map((v) => (v.match(/<([^>]+)>/)?.[1] ?? "").toLowerCase()).filter(Boolean),
|
|
@@ -79,6 +88,20 @@ export async function listCommits(cwd, opts) {
|
|
|
79
88
|
}
|
|
80
89
|
return commits;
|
|
81
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* `%aI` carries the author's offset, for example 2026-03-02T00:30:00+13:00. `new Date` keeps the
|
|
93
|
+
* instant and drops the offset, so a commit made on Monday morning in Auckland lands in the
|
|
94
|
+
* previous UTC week. This shifts the instant by the stated offset so the UTC getters read back
|
|
95
|
+
* the author's own calendar.
|
|
96
|
+
*/
|
|
97
|
+
export function wallClock(iso) {
|
|
98
|
+
const m = iso.match(/([+-])(\d{2}):?(\d{2})$/);
|
|
99
|
+
const instant = new Date(iso);
|
|
100
|
+
if (!m || /[Zz]$/.test(iso))
|
|
101
|
+
return instant;
|
|
102
|
+
const minutes = (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
|
|
103
|
+
return new Date(instant.getTime() + minutes * 60000);
|
|
104
|
+
}
|
|
82
105
|
export async function listTags(cwd) {
|
|
83
106
|
const out = await git(["for-each-ref", "--format=%(refname:short)%09%(creatordate:iso-strict)%09%(taggeremail)%09%(*authoremail)%09%(authoremail)", "refs/tags"], cwd);
|
|
84
107
|
const tags = [];
|
package/dist/src/report.js
CHANGED
|
@@ -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.
|
|
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) {
|
package/dist/src/schema.js
CHANGED
|
@@ -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
|
+
"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": [
|
|
7
|
+
"required": [
|
|
8
|
+
"tool",
|
|
9
|
+
"schemaVersion",
|
|
10
|
+
"version",
|
|
11
|
+
"generatedAt",
|
|
12
|
+
"params",
|
|
13
|
+
"repositories",
|
|
14
|
+
"hash"
|
|
15
|
+
],
|
|
8
16
|
"properties": {
|
|
9
|
-
"tool": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
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": [
|
|
42
|
+
"required": [
|
|
43
|
+
"name",
|
|
44
|
+
"head",
|
|
45
|
+
"fingerprint",
|
|
46
|
+
"identity",
|
|
47
|
+
"environment",
|
|
48
|
+
"excluded",
|
|
49
|
+
"figures"
|
|
50
|
+
],
|
|
21
51
|
"properties": {
|
|
22
|
-
"name": {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
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": [
|
|
68
|
+
"required": [
|
|
69
|
+
"emails",
|
|
70
|
+
"names",
|
|
71
|
+
"count"
|
|
72
|
+
],
|
|
29
73
|
"properties": {
|
|
30
|
-
"emails": {
|
|
31
|
-
|
|
32
|
-
|
|
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": [
|
|
121
|
+
"required": [
|
|
122
|
+
"git",
|
|
123
|
+
"blame"
|
|
124
|
+
],
|
|
38
125
|
"properties": {
|
|
39
|
-
"git": {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
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": [
|
|
148
|
+
"required": [
|
|
149
|
+
"botCommits",
|
|
150
|
+
"files",
|
|
151
|
+
"linesAddedShare"
|
|
152
|
+
],
|
|
48
153
|
"properties": {
|
|
49
|
-
"botCommits": {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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": [
|
|
177
|
+
"required": [
|
|
178
|
+
"id",
|
|
179
|
+
"title",
|
|
180
|
+
"value",
|
|
181
|
+
"command",
|
|
182
|
+
"limits"
|
|
183
|
+
],
|
|
61
184
|
"properties": {
|
|
62
|
-
"id": {
|
|
63
|
-
|
|
185
|
+
"id": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"minLength": 1
|
|
188
|
+
},
|
|
189
|
+
"title": {
|
|
190
|
+
"type": "string"
|
|
191
|
+
},
|
|
64
192
|
"value": {},
|
|
65
|
-
"command": {
|
|
66
|
-
|
|
193
|
+
"command": {
|
|
194
|
+
"type": "string"
|
|
195
|
+
},
|
|
196
|
+
"limits": {
|
|
197
|
+
"type": "array",
|
|
198
|
+
"items": {
|
|
199
|
+
"type": "string"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
67
202
|
}
|
|
68
203
|
}
|
|
69
204
|
}
|