workproof 0.3.1 → 0.4.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
@@ -175,6 +175,48 @@ so it never merges them for you: pass each address to `--author`, or write a `.m
175
175
  The addresses themselves stay out of the document unless you pass `--emails`, the same rule the
176
176
  rest of the report follows.
177
177
 
178
+ ## Names that are not ASCII
179
+
180
+ The identity fold decides which commits are yours, so a gap in it is a missing figure rather than
181
+ an ugly one. Two gaps were closed against Unicode 17.0, and both changed counts:
182
+
183
+ - `Weiß` and `WEISS` are one person. JavaScript's `toLowerCase` is Unicode's simple case fold and
184
+ leaves ß alone, so the two spellings never met and half of one person's commits fell outside
185
+ every figure. The full fold maps ß to ss
186
+ ([CaseFolding.txt](https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt), status `F`),
187
+ which is what the tool does now.
188
+ - A name typed in fullwidth Latin letters, which is what a Japanese or Korean keyboard produces
189
+ without switching modes, is the same name typed in ASCII. Matching normalises to NFKC, so the
190
+ two forms meet, along with the fi ligature and the compatibility ideographs.
191
+
192
+ Turkish dotted and dotless i and decomposed accents were already folded. The fold is for matching
193
+ only. The report always prints the name git holds.
194
+
195
+ Display is separate. An Arabic or Hebrew name is the first strong character of the plain-language
196
+ paragraph, and without an isolate it turns the whole paragraph right to left: the English after it
197
+ reverses and the figures land in the wrong order. Names and repository names are wrapped in U+2068
198
+ and U+2069 in the Markdown report, the isolates [UAX #9](https://www.unicode.org/reports/tr9/)
199
+ defines for this. They are invisible, they are added only where a strong right-to-left character
200
+ appears, and they are never written into the JSON, so the hash over a report is unchanged and a
201
+ report written before this still verifies.
202
+
203
+ Three things here are deliberate:
204
+
205
+ - Numbers are printed in the `en-US` format wherever a person reads them, so `12,345` is twelve
206
+ thousand for every reader. The hash is taken over the RFC 8785 canonical JSON, where numbers
207
+ are not formatted at all, so this cannot affect `workproof verify`. It is about the document:
208
+ two reports are meant to be read side by side, and a thousands separator that follows the
209
+ reader's machine makes that harder for no gain.
210
+ - Weeks are ISO weeks, starting on Monday. That is not the local week for most of the world: of
211
+ the twenty most populous countries, CLDR gives Monday to seven, Sunday to eleven and Saturday
212
+ to two, which `Intl.Locale.prototype.getWeekInfo` reports in Node 24. Cadence counts weeks a
213
+ person was active, and that figure is only worth reading next to another report, so it uses one
214
+ definition everywhere rather than the reader's. The days inside a week are already the author's
215
+ own local dates, which is what decides whether a Sunday commit belongs to this week or the next.
216
+ - A `.mailmap` name is matched by git, not by this tool. git compares those names ignoring case
217
+ for ASCII letters only, verified on git 2.50.1: an entry written `josÉ Álvarez` matches a commit
218
+ by `JOSÉ ÁLVAREZ`, and one written `josé álvarez` does not. Map by address wherever you can.
219
+
178
220
  ## In plain language
179
221
 
180
222
  Each repository section of the Markdown report opens with a paragraph a non-engineer can
@@ -229,6 +271,16 @@ Three commands, in increasing order of what the reader needs to have.
229
271
  compares HEAD, then recomputes every figure and prints what differs. A report from another
230
272
  repository stops at the fingerprint. If HEAD moved since the report, it says so and shows
231
273
  which figures changed.
274
+ The hash is reproducible **across operating systems, git versions, locales and directory
275
+ names**, for the same repository at the same commit with the same command and the same
276
+ `--fingerprint-key`. It is not reproducible across a rewritten history, a different sample
277
+ or a different seed, and it is not meant to be: those change the measurement. Two fields
278
+ in the report are deliberately outside the hash, because a verifier on another machine
279
+ cannot reproduce them: the local directory name, and the git version. They stay in the
280
+ document, where they explain why two runs might legitimately differ. Before 0.4.0 both
281
+ were inside it, so the same repository in a folder with a different name hashed
282
+ differently; a test now pins all of this, and CI checks it on Linux, macOS and Windows.
283
+
232
284
  - `workproof attest report.json` writes `report.intoto.json`, an
233
285
  [in-toto](https://in-toto.io) v1 Statement whose subject is the report hash and whose
234
286
  predicate carries the tool version, parameters, HEAD, keyed fingerprint, git version and
@@ -483,6 +535,28 @@ with an attestation if the reader cannot reach the repository.
483
535
  It measures survivorship and activity, not quality, review, design or mentoring. It does
484
536
  not rank people. It does not replace references. It is not a legal document.
485
537
 
538
+ ## What the research says about numbers like these
539
+
540
+ Repository-derived figures have a literature, and most of it is a warning. Kalliamvakou,
541
+ Gousios and Blincoe catalogued the traps in mining a forge, including that most repositories
542
+ are personal and inactive and that a large share of merged pull requests are not recorded as
543
+ merged ([MSR 2014](https://doi.org/10.1145/2597073.2597074), extended in [EMSE
544
+ 2015](https://doi.org/10.1007/s10664-015-9393-5)). Bird and colleagues related ownership
545
+ concentration to defects rather than to credit, which is a different question from this one
546
+ and is not evidence that a high share means good work. On survival, Spinellis, Louridas and
547
+ Kechagia put the median line lifespan at about 2.4 years across 89 repositories ([PeerJ CS
548
+ 7:e372, 2021](https://doi.org/10.7717/peerj-cs.372)), and Gurov found over half of all lines
549
+ are never deleted in 120 TypeScript repositories ([arXiv
550
+ 2606.04993](https://arxiv.org/abs/2606.04993)).
551
+
552
+ None of that work measures a person's share of the surviving code, which is what the
553
+ surviving-lines figure here reports. A first measurement of how far that share diverges from
554
+ commit share, over twelve repositories, is in
555
+ [surviving-lines/research](https://github.com/Bubblegunn/surviving-lines/tree/main/research):
556
+ the same person tops both in 25 of 36 runs, and among substantial contributors the two
557
+ differ by a median of 7.5 percentage points. That is the honest size of the effect this
558
+ report exists to expose, measured rather than asserted.
559
+
486
560
  ## Where it comes from
487
561
 
488
562
  The method is written up in
package/README.tr.md CHANGED
@@ -115,6 +115,49 @@ sayılmadan paydadan çıktı; bu, aynı kişinin eski sürümde %24,1 görünen
115
115
  adlandırırdı. Bu boşluk, iki yönde de, bir raporun birinin işi hakkında söyleyebileceği en
116
116
  dürüst şeydir.
117
117
 
118
+ ## ASCII olmayan isimler
119
+
120
+ Kimlik katlaması hangi commit'lerin size ait olduğuna karar verir; oradaki bir boşluk çirkin bir
121
+ görüntü değil, eksik bir figür demektir. Unicode 17.0'a bakılarak iki boşluk kapatıldı ve ikisi de
122
+ sayıları değiştirdi:
123
+
124
+ - `Weiß` ile `WEISS` tek kişidir. JavaScript'in `toLowerCase`'i Unicode'un basit harf katlamasıdır
125
+ ve ß'ye dokunmaz; iki yazım hiç buluşmuyordu ve bir kişinin commit'lerinin yarısı bütün
126
+ figürlerin dışında kalıyordu. Tam katlama ß'yi ss'ye eşler
127
+ ([CaseFolding.txt](https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt), durum `F`)
128
+ ve araç artık bunu yapıyor.
129
+ - Tam genişlikli Latin harfleriyle yazılan bir isim, ki Japonca ya da Korece bir klavyede mod
130
+ değiştirmeden yazılan budur, ASCII ile yazılan aynı isimdir. Eşleştirme NFKC'ye normalleştirir;
131
+ fi bağlaması ve uyumluluk ideografları da bununla birlikte katlanır.
132
+
133
+ Türkçe noktalı ve noktasız i ile ayrışık aksanlar zaten katlanıyordu. Katlama yalnız eşleştirme
134
+ içindir; rapor her zaman git'in tuttuğu ismi yazar.
135
+
136
+ Görüntü ayrı bir konu. Arapça ya da İbranice bir isim, sade dil paragrafının ilk güçlü
137
+ karakteridir ve yalıtıcı olmadan bütün paragrafı sağdan sola çevirir: ardındaki İngilizce ters
138
+ döner, sayılar yanlış sırada görünür. Markdown raporunda isimler ve depo adları U+2068 ile U+2069
139
+ arasına alınıyor; [UAX #9](https://www.unicode.org/reports/tr9/) bu yalıtıcıları tam olarak bunun
140
+ için tanımlar. Görünmezler, yalnız güçlü sağdan sola karakter geçtiğinde eklenirler ve JSON'a hiç
141
+ yazılmazlar; yani rapor özeti değişmez ve bu değişiklikten önce yazılmış bir rapor hâlâ doğrulanır.
142
+
143
+ Üç şey bilerek böyle:
144
+
145
+ - Sayılar, insanın okuduğu her yerde `en-US` biçiminde yazılır; `12,345` her okuyucu için on iki
146
+ bindir. Özet, sayıların hiç biçimlendirilmediği RFC 8785 kanonik JSON üzerinden alınır, yani bu
147
+ seçim `workproof verify`'ı etkileyemez. Mesele belgedir: iki rapor yan yana okunmak için yazılır
148
+ ve okuyucunun makinesine göre değişen bir binlik ayıracı bunu boşuna zorlaştırır.
149
+ - Haftalar ISO haftasıdır, pazartesi başlar. Dünyanın çoğu için yerel hafta bu değildir: en
150
+ kalabalık yirmi ülkeden CLDR'ye göre yedisinde pazartesi, on birinde pazar, ikisinde cumartesi
151
+ başlar; Node 24'te bunu `Intl.Locale.prototype.getWeekInfo` söyler. Cadence, bir kişinin etkin
152
+ olduğu haftaları sayar ve bu figür ancak başka bir raporun yanında anlamlıdır, o yüzden
153
+ okuyucunun tanımını değil her yerde aynı tanımı kullanır. Haftanın içindeki günler zaten yazarın
154
+ kendi yerel tarihleridir; bir pazar commit'inin bu haftaya mı yoksa sonrakine mi düştüğüne o
155
+ karar verir.
156
+ - `.mailmap` isimlerini bu araç değil git eşleştirir. git bu isimleri yalnız ASCII harfleri için
157
+ büyük küçük harf farkını yok sayarak karşılaştırır; git 2.50.1 üzerinde doğrulandı:
158
+ `josÉ Álvarez` yazılmış bir satır `JOSÉ ÁLVAREZ` imzalı bir commit ile eşleşiyor,
159
+ `josé álvarez` yazılmış olan eşleşmiyor. Mümkün olan her yerde adresle eşleyin.
160
+
118
161
  ## Ne ölçer
119
162
 
120
163
  Her figür git'ten gelir, başka hiçbir şeyden değil. Bot commit'leri ile üretilmiş, vendored,
@@ -13,6 +13,19 @@ import type { Identity } from "./types.js";
13
13
  * turn English "I" into "ı", so the four i forms collapse to one instead. The cost is that two
14
14
  * names differing only in dotted and dotless i match each other; the benefit is that a Turkish
15
15
  * name matches itself in any case, and the error message prints the folded form that was tried.
16
+ *
17
+ * The German sharp s. `"WEISS".toLowerCase()` is "weiss" and `"Weiß".toLowerCase()` is "weiß",
18
+ * because JavaScript's `toLowerCase` is Unicode's simple case fold. The full fold maps ß to ss
19
+ * (CaseFolding.txt, status F, https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt), so
20
+ * ß and capital ẞ are mapped before it runs. Without this, one person who signs both ways was two
21
+ * people, and half their commits were left out of every figure.
22
+ *
23
+ * Compatibility spellings. A name typed in fullwidth Latin letters, which is what a Japanese or
24
+ * Korean keyboard produces without switching modes, is the same name typed in ASCII; NFKC is what
25
+ * says so. It folds the fi ligature and the compatibility ideographs with it.
26
+ *
27
+ * This fold decides which commits are counted, so a gap in it is a wrong figure rather than a
28
+ * cosmetic problem. It is never a display form: the report prints the name git holds.
16
29
  */
17
30
  export declare function foldIdentity(s: string): string;
18
31
  /**
@@ -12,14 +12,28 @@ import { configuredEmail, configuredName } from "../git.js";
12
12
  * turn English "I" into "ı", so the four i forms collapse to one instead. The cost is that two
13
13
  * names differing only in dotted and dotless i match each other; the benefit is that a Turkish
14
14
  * name matches itself in any case, and the error message prints the folded form that was tried.
15
+ *
16
+ * The German sharp s. `"WEISS".toLowerCase()` is "weiss" and `"Weiß".toLowerCase()` is "weiß",
17
+ * because JavaScript's `toLowerCase` is Unicode's simple case fold. The full fold maps ß to ss
18
+ * (CaseFolding.txt, status F, https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt), so
19
+ * ß and capital ẞ are mapped before it runs. Without this, one person who signs both ways was two
20
+ * people, and half their commits were left out of every figure.
21
+ *
22
+ * Compatibility spellings. A name typed in fullwidth Latin letters, which is what a Japanese or
23
+ * Korean keyboard produces without switching modes, is the same name typed in ASCII; NFKC is what
24
+ * says so. It folds the fi ligature and the compatibility ideographs with it.
25
+ *
26
+ * This fold decides which commits are counted, so a gap in it is a wrong figure rather than a
27
+ * cosmetic problem. It is never a display form: the report prints the name git holds.
15
28
  */
16
29
  export function foldIdentity(s) {
17
30
  return s
18
- .normalize("NFC")
31
+ .normalize("NFKC")
19
32
  .replace(/[\u0130\u0131Ii]/g, "i")
20
33
  .replace(/\u0307/g, "")
34
+ .replace(/[\u00df\u1e9e]/g, "ss")
21
35
  .toLowerCase()
22
- .normalize("NFC")
36
+ .normalize("NFKC")
23
37
  .trim();
24
38
  }
25
39
  /**
@@ -10,7 +10,6 @@ export interface Report {
10
10
  /** sha256 of the RFC 8785 canonical JSON of { params, repositories }. */
11
11
  hash: string;
12
12
  }
13
- /** The hash a report with these parameters and repositories must carry. */
14
13
  export declare const hashOf: (params: unknown, repositories: unknown) => string;
15
14
  export declare function buildReport(repositories: RepoReport[], params: Params, meta: {
16
15
  version: string;
@@ -2,8 +2,36 @@ import { createHash } from "node:crypto";
2
2
  import { canonicalize } from "./canonical.js";
3
3
  import { publicEmail } from "./analyse.js";
4
4
  import { plainSummary } from "./summary.js";
5
+ import { isolate } from "./text.js";
5
6
  /** The hash a report with these parameters and repositories must carry. */
6
- export const hashOf = (params, repositories) => createHash("sha256").update(canonicalize({ params, repositories })).digest("hex");
7
+ /**
8
+ * What of a repository the hash covers: everything a stranger with the same repository at
9
+ * the same commit can reproduce, and nothing else.
10
+ *
11
+ * Two fields are recorded in the report and deliberately left out. `name` is the local
12
+ * directory's basename, so the same repository cloned to a different folder hashed
13
+ * differently. `environment.git` is the local git version string, so two people with the
14
+ * same repository and different git hashed differently. Both were reproduced with real
15
+ * runs before 0.4.0. They stay in the document because they explain why two runs might
16
+ * legitimately differ; they are not something a verifier can be asked to reproduce.
17
+ *
18
+ * The blame flags, the ignore-revs file and the seed stay in, because those are the
19
+ * question the caller asked, not the machine they asked it on.
20
+ */
21
+ function forHash(repositories) {
22
+ if (!Array.isArray(repositories))
23
+ return repositories;
24
+ return repositories.map((repo) => {
25
+ if (repo === null || typeof repo !== "object")
26
+ return repo;
27
+ const { name: _name, environment, ...rest } = repo;
28
+ if (environment === null || typeof environment !== "object")
29
+ return rest;
30
+ const { git: _git, ...restEnvironment } = environment;
31
+ return { ...rest, environment: restEnvironment };
32
+ });
33
+ }
34
+ export const hashOf = (params, repositories) => createHash("sha256").update(canonicalize({ params, repositories: forHash(repositories) })).digest("hex");
7
35
  /**
8
36
  * What of the parameters the report may carry: never the fingerprint key, never a GitHub
9
37
  * noreply login, and without --emails no author given as an email address.
@@ -72,9 +100,9 @@ export function renderMarkdown(report, narrative) {
72
100
  ``,
73
101
  ];
74
102
  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.identity.possiblySplit
103
+ out.push(`## ${isolate(repo.name)}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.map(isolate).join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`, ``, ...(repo.identity.possiblySplit
76
104
  ? [
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\`.`,
105
+ `**${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.map(isolate).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
106
  ``,
79
107
  ]
80
108
  : []), repo.excluded.enabled
@@ -1,3 +1,4 @@
1
+ import { isolate } from "./text.js";
1
2
  const pct = (x) => `${Math.round(x * 100)}%`;
2
3
  const n = (x) => x.toLocaleString("en-US");
3
4
  const value = (repo, id) => repo.figures.find((f) => f.id === id)?.value;
@@ -10,7 +11,9 @@ function longDate(iso) {
10
11
  return `${d} ${MONTHS[m - 1]} ${y}`;
11
12
  }
12
13
  export function plainSummary(repo) {
13
- const who = repo.identity.names[0] ?? "This author";
14
+ // Isolated so a right-to-left name at the head of the paragraph cannot reverse the sentence
15
+ // around it. Presentation only: the data, and the hash over it, keep the name git holds.
16
+ const who = isolate(repo.identity.names[0] ?? "This author");
14
17
  const tenure = value(repo, "tenure");
15
18
  const commits = value(repo, "commitShare");
16
19
  const surviving = value(repo, "survivingLines");
@@ -19,7 +22,7 @@ export function plainSummary(repo) {
19
22
  const absence = value(repo, "absenceFactor");
20
23
  const sentences = [];
21
24
  if (tenure) {
22
- sentences.push(`${who} worked in ${repo.name} from ${longDate(tenure.first)} to ${longDate(tenure.last)}, a span of ${n(tenure.days)} days.`);
25
+ sentences.push(`${who} worked in ${isolate(repo.name)} from ${longDate(tenure.first)} to ${longDate(tenure.last)}, a span of ${n(tenure.days)} days.`);
23
26
  }
24
27
  if (commits && surviving) {
25
28
  sentences.push(`They made ${n(commits.author)} of the ${n(commits.total)} changes recorded in that period (${pct(commits.share)}), and ${n(surviving.lines)} of the ${n(surviving.linesAttributed)} lines of code still in the project today are theirs (${pct(surviving.share)}).`);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Text that has to survive being embedded in someone else's sentence.
3
+ */
4
+ /**
5
+ * Wrap a name, a repository name or any other borrowed string in the first strong isolate, so it
6
+ * cannot reorder the sentence it is dropped into.
7
+ *
8
+ * The plain-language paragraph puts the author's name at the start of a sentence and the
9
+ * repository's name in the middle of it. An Arabic or Hebrew name there is the first strong
10
+ * character of the paragraph, which under the bidirectional algorithm makes the whole paragraph
11
+ * right to left: the English words after it reverse, and the numbers land in the wrong order.
12
+ * U+2068 and U+2069 are what UAX #9 provides for embedding text of unknown direction
13
+ * (https://www.unicode.org/reports/tr9/, section 2.7). They are default-ignorable, so they take
14
+ * no width, and they are added only to strings that carry strong right-to-left characters, so
15
+ * every other report is byte for byte what it was.
16
+ *
17
+ * This is presentation only. The report's data, and therefore its hash, keeps the exact string
18
+ * git holds: `workproof verify` on a report written before this change still passes.
19
+ */
20
+ export declare function isolate(s: string): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Text that has to survive being embedded in someone else's sentence.
3
+ */
4
+ /** Scripts written right to left, whose first strong character sets a paragraph's direction. */
5
+ const RTL = /[\p{sc=Arabic}\p{sc=Hebrew}\p{sc=Syriac}\p{sc=Thaana}\p{sc=Nko}\p{sc=Samaritan}\p{sc=Mandaic}\p{sc=Adlam}]/u;
6
+ /**
7
+ * Wrap a name, a repository name or any other borrowed string in the first strong isolate, so it
8
+ * cannot reorder the sentence it is dropped into.
9
+ *
10
+ * The plain-language paragraph puts the author's name at the start of a sentence and the
11
+ * repository's name in the middle of it. An Arabic or Hebrew name there is the first strong
12
+ * character of the paragraph, which under the bidirectional algorithm makes the whole paragraph
13
+ * right to left: the English words after it reverse, and the numbers land in the wrong order.
14
+ * U+2068 and U+2069 are what UAX #9 provides for embedding text of unknown direction
15
+ * (https://www.unicode.org/reports/tr9/, section 2.7). They are default-ignorable, so they take
16
+ * no width, and they are added only to strings that carry strong right-to-left characters, so
17
+ * every other report is byte for byte what it was.
18
+ *
19
+ * This is presentation only. The report's data, and therefore its hash, keeps the exact string
20
+ * git holds: `workproof verify` on a report written before this change still passes.
21
+ */
22
+ export function isolate(s) {
23
+ if (!RTL.test(s) || /\p{Bidi_Control}/u.test(s))
24
+ return s;
25
+ return `⁨${s}⁩`;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workproof",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",