workproof 0.4.0 → 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
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
  /**
@@ -2,6 +2,7 @@ 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
7
  /**
7
8
  * What of a repository the hash covers: everything a stranger with the same repository at
@@ -99,9 +100,9 @@ export function renderMarkdown(report, narrative) {
99
100
  ``,
100
101
  ];
101
102
  for (const repo of report.repositories) {
102
- 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
103
104
  ? [
104
- `**${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\`.`,
105
106
  ``,
106
107
  ]
107
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.4.0",
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",