workproof 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Efe Genc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ <p align="center"><img src="assets/wordmark.svg" width="480" alt="workproof"></p>
2
+
3
+ <p align="center"><em>Your best work is in private repos. Prove it anyway.</em></p>
4
+
5
+ <p align="center">
6
+ <img src="https://img.shields.io/npm/v/workproof?style=flat-square&color=111111&label=npm" alt="npm">
7
+ <img src="https://img.shields.io/github/stars/Bubblegunn/workproof?style=flat-square&color=111111" alt="stars">
8
+ <img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
9
+ </p>
10
+
11
+ workproof turns a git repository into a verifiable engineering report for one author,
12
+ without showing any code. You run it in the repository you cannot share. The reader
13
+ gets six figures, the exact command behind each one, what each one cannot show, and a
14
+ hash. Anyone with the same repository can rerun `verify` and see whether the numbers
15
+ reproduce.
16
+
17
+ ## 30 seconds
18
+
19
+ ```
20
+ cd your-private-repo
21
+ npx workproof
22
+ ```
23
+
24
+ That writes `workproof-report.md` (paste it into a résumé, a portfolio, a visa application)
25
+ and `workproof-report.json` (for tools, and for verification). To check someone's report:
26
+
27
+ ```
28
+ npx workproof verify workproof-report.json
29
+ ```
30
+
31
+ ## What a report looks like
32
+
33
+ This is real output for one maintainer of [langchain-ai/openwiki](https://github.com/langchain-ai/openwiki)
34
+ at `1e6d54c`, run on 5 September 2026 with `--author "Colin Francis" --sample 5`, paths and
35
+ emails hidden (the defaults):
36
+
37
+ ```
38
+ ## openwiki
39
+
40
+ HEAD 1e6d54cdfeec · fingerprint 82aa401bbba056f1 · identities: Colin Francis
41
+
42
+ ### Tenure window
43
+ 2026-07-06 to 2026-09-03 (60 days)
44
+
45
+ ### Share of commits in tenure
46
+ 71 of 295 non-merge commits, 24.1%
47
+
48
+ ### Cadence
49
+ 9 active weeks of 9, 7.9 commits per active week, longest streak 9 weeks
50
+ 1 of 21 release tags in tenure
51
+
52
+ ### Footprint
53
+ 694 files touched
54
+ 16 directories with a commit share at or above the threshold (paths hidden; run with --paths)
55
+ languages by lines added: TypeScript 80.4%, JSON 9.6%, Markdown 7.0%, JavaScript 1.6%, YAML 1.4%
56
+
57
+ ### Tests and documentation
58
+ 393 of 657 test-file changes, 59.8%
59
+ 116 documents authored
60
+
61
+ ### Surviving lines at HEAD
62
+ 23,317 of 33,038 surviving lines, 70.6% (files 123/548, sample 1 in 5)
63
+ ```
64
+
65
+ Under every figure the report prints two more lines: `How:` with the git command that
66
+ produced it, and `What this cannot show:`. The last section is `Integrity`: the report
67
+ hash and the repository fingerprint.
68
+
69
+ Read the two shares together. This person wrote 24.1% of the commits in their window and
70
+ 70.6% of the lines that are still alive. A commit count would have called them a minor
71
+ contributor. That gap, in either direction, is usually the most honest thing a report can
72
+ say about someone's work.
73
+
74
+ ## What it measures
75
+
76
+ All six figures come from git and nothing else.
77
+
78
+ | figure | what it is | what it cannot show |
79
+ |---|---|---|
80
+ | Tenure window | first to last commit by the author, or `--since/--until` | work before the first commit or after the last |
81
+ | Share of commits | non-merge commits by the author over all non-merge commits in the window | what survived; a typo and a subsystem count the same |
82
+ | 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 |
83
+ | Footprint | files touched, directories at or above a commit-share threshold, languages by lines added | generated and vendored files inflate whoever committed them |
84
+ | Tests and docs | share of test-file changes, documents authored | test cases, coverage, or the quality of a document |
85
+ | Surviving lines | share of lines alive at HEAD, `git blame -w -M` over a deterministic file sample, via [surviving-lines](https://github.com/Bubblegunn/surviving-lines) | merit; code deleted on purpose counts for nobody |
86
+
87
+ ## How verification works
88
+
89
+ - The JSON carries the repository's HEAD, a **fingerprint** (sha256 of the root commit and
90
+ the normalised remote URL, so the repository is identified without being named), the
91
+ identity names used, the `surviving-lines` version, every parameter, and a **hash** of
92
+ the parameters and figures.
93
+ - `workproof verify report.json` recomputes every figure in the repository you point it at
94
+ and prints a match table. If HEAD moved since the report, it says so and shows which
95
+ figures changed.
96
+ - A hiring manager needs two things: the report and read access to the repository (or a
97
+ colleague inside the company who will run one command). Nothing leaves the repository.
98
+
99
+ ## Privacy
100
+
101
+ - No code content, ever. The tool reads `git log --numstat` and `git blame`, and emits
102
+ counts.
103
+ - No file paths by default. `--paths` adds directory names at the configured `--depth`
104
+ (default 2), never files.
105
+ - No email addresses by default. `--emails` adds them; without it, even the `--author`
106
+ you typed is replaced by `(email hidden)` in the stored parameters.
107
+ - The optional narrative (`--narrate`) sends the figures, and only the figures, to a
108
+ model endpoint you choose (`WORKPROOF_API_URL`, `WORKPROOF_API_KEY`, `WORKPROOF_MODEL`;
109
+ OpenAI-compatible or Anthropic). The paragraph is appended under
110
+ "Generated narrative (not verified)" and is excluded from the hash.
111
+
112
+ ## Options
113
+
114
+ ```
115
+ workproof [options] [--repo <dir>]...
116
+ workproof verify <report.json> [--repo <dir>]...
117
+
118
+ --author <email|name> identity to report on (repeatable; default: git config user.email)
119
+ --repo <dir> repository to analyse (repeatable; several produce one combined report)
120
+ --since / --until override the tenure window
121
+ --sample <n> blame every n-th file (default: 1; 7 for very large repositories)
122
+ --depth <n> directory depth for ownership (default: 2)
123
+ --paths include directory paths
124
+ --emails include author emails
125
+ --narrate append a model-written paragraph
126
+ --out <basename> output basename (default: workproof-report)
127
+ --json print the JSON to stdout instead of writing files
128
+ ```
129
+
130
+ A `.mailmap` in the repository merges an author's several addresses.
131
+
132
+ ## For candidates
133
+
134
+ Run it in each repository you are proud of and cannot show. Put the Markdown in your
135
+ portfolio next to the sentence you would have written anyway ("I built the frontend"),
136
+ and let the numbers carry the sentence. Keep the JSON; it is what a reviewer verifies.
137
+
138
+ ## For hiring managers
139
+
140
+ Ask for the JSON and for someone inside the candidate's former company to run
141
+ `npx workproof verify` on it. The table either reproduces or it does not. If the
142
+ repository has moved on, the tool says which figures changed and why that is expected.
143
+
144
+ ## For visa and immigration evidence
145
+
146
+ workproof was built for a UK Global Talent application, where the strongest work was in
147
+ private repositories and "trust me" is not evidence. A report is a measurement with its
148
+ method attached, not an endorsement; pair it with letters from people who were there.
149
+
150
+ ## What it does not do
151
+
152
+ It measures survivorship and activity, not quality, review, design or mentoring. It does
153
+ not rank people. It does not replace references. It is not a legal document.
154
+
155
+ ## Where it comes from
156
+
157
+ The method is written up in
158
+ [How to show engineering ownership when the repositories are private](https://efe-genc-portfolio.vercel.app/writing/showing-ownership-private-repositories/).
159
+ The blame sampling is [surviving-lines](https://github.com/Bubblegunn/surviving-lines),
160
+ workproof's only dependency.
161
+
162
+ ## Development
163
+
164
+ ```
165
+ npm ci
166
+ npm test # tsc build, then node:test over the compiled tests (fixture repositories built in a temp dir)
167
+ ```
168
+
169
+ MIT.
@@ -0,0 +1,26 @@
1
+ import type { Figure } from "./figures/types.js";
2
+ export interface Params {
3
+ author?: string[];
4
+ since?: string;
5
+ until?: string;
6
+ sample?: number;
7
+ depth: number;
8
+ threshold: number;
9
+ minCommits: number;
10
+ paths: boolean;
11
+ emails: boolean;
12
+ }
13
+ export interface RepoReport {
14
+ name: string;
15
+ head: string;
16
+ fingerprint: string;
17
+ identity: {
18
+ emails: string[];
19
+ names: string[];
20
+ count: number;
21
+ };
22
+ figures: Figure<any>[];
23
+ }
24
+ /** sha256 of the root commit and the normalised remote: identifies a repository without naming it. */
25
+ export declare function fingerprint(root: string, remote: string): string;
26
+ export declare function analyseRepo(cwd: string, params: Params): Promise<RepoReport>;
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { listCommits, listTags, rootCommit, headSha, remoteUrl } from "./git.js";
5
+ import { resolveIdentity } from "./figures/identity.js";
6
+ import { tenure, commitShare } from "./figures/commits.js";
7
+ import { cadence } from "./figures/cadence.js";
8
+ import { footprint, testsAndDocs } from "./figures/footprint.js";
9
+ import { survivingLines } from "./figures/surviving.js";
10
+ /** sha256 of the root commit and the normalised remote: identifies a repository without naming it. */
11
+ export function fingerprint(root, remote) {
12
+ let r = remote.trim().toLowerCase().replace(/\.git$/, "");
13
+ r = r.replace(/^[a-z+]+:\/\//, "").replace(/^git@([^:]+):/, "$1/");
14
+ return createHash("sha256").update(`${root}\n${r}`).digest("hex");
15
+ }
16
+ const require = createRequire(import.meta.url);
17
+ const survivingVersion = () => {
18
+ try {
19
+ return require("surviving-lines/package.json").version;
20
+ }
21
+ catch {
22
+ return "unknown";
23
+ }
24
+ };
25
+ export async function analyseRepo(cwd, params) {
26
+ const all = await listCommits(cwd, {});
27
+ const id = await resolveIdentity(all, params.author, cwd);
28
+ const t = tenure(all, id, { ...(params.since ? { since: params.since } : {}), ...(params.until ? { until: params.until } : {}) });
29
+ const start = new Date(t.value.first + "T00:00:00Z");
30
+ const end = new Date(t.value.last + "T23:59:59Z");
31
+ const inTenure = all.filter((c) => c.date >= start && c.date <= end);
32
+ const tags = await listTags(cwd);
33
+ const sample = params.sample ?? (all.reduce((n, c) => n + c.files.length, 0) > 50000 ? 7 : 1);
34
+ const fp = footprint(inTenure, id, { depth: params.depth, threshold: params.threshold, minCommits: params.minCommits });
35
+ if (!params.paths) {
36
+ fp.value = { ...fp.value, ownedDirectories: fp.value.ownedDirectories.map((d) => ({ ...d, path: "(hidden; run with --paths)" })) };
37
+ }
38
+ const figures = [
39
+ t,
40
+ commitShare(inTenure, id),
41
+ cadence(inTenure, tags, id, { first: t.value.first, last: t.value.last }),
42
+ fp,
43
+ testsAndDocs(inTenure, id),
44
+ await survivingLines(cwd, id, { sample, version: survivingVersion() }),
45
+ ];
46
+ return {
47
+ name: basename(cwd),
48
+ head: await headSha(cwd),
49
+ fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd)),
50
+ identity: { emails: params.emails ? id.emails : [], names: id.names, count: id.emails.length },
51
+ figures,
52
+ };
53
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import type { Params } from "./index.js";
3
+ interface Cli {
4
+ params: Params;
5
+ repos: string[];
6
+ out: string;
7
+ json: boolean;
8
+ doNarrate: boolean;
9
+ verifyFile: string | undefined;
10
+ }
11
+ export declare function parse(argv: string[]): Cli;
12
+ export {};
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ import { analyseRepo, buildReport, renderMarkdown, verifyReport, narrate } from "./index.js";
7
+ const HELP = `usage: workproof [options] [--repo <dir>]...
8
+ workproof verify <report.json> [--repo <dir>]...
9
+
10
+ Turn a git repository into a verifiable engineering report for one author, without showing code.
11
+
12
+ --author <email|name> identity to report on (repeatable; default: git config user.email)
13
+ --repo <dir> repository to analyse (repeatable; default: current directory)
14
+ --since / --until override the tenure window (dates git understands)
15
+ --sample <n> blame every n-th file (default: 1, or 7 for very large repositories)
16
+ --depth <n> directory depth for ownership (default: 2)
17
+ --paths include directory paths in the report (off by default)
18
+ --emails include author emails in the report (off by default)
19
+ --narrate append a model-written paragraph; needs WORKPROOF_API_URL, WORKPROOF_API_KEY, WORKPROOF_MODEL
20
+ --out <basename> output basename (default: workproof-report)
21
+ --json print the JSON to stdout instead of writing files
22
+ -h, --help this text`;
23
+ export function parse(argv) {
24
+ const params = { depth: 2, threshold: 0.5, minCommits: 5, paths: false, emails: false };
25
+ const repos = [];
26
+ const authors = [];
27
+ let out = "workproof-report";
28
+ let json = false;
29
+ let doNarrate = false;
30
+ let verifyFile;
31
+ if (argv[0] === "verify") {
32
+ verifyFile = argv[1];
33
+ if (!verifyFile)
34
+ throw new Error("verify needs a report.json");
35
+ argv = argv.slice(2);
36
+ }
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ const next = () => {
40
+ const v = argv[++i];
41
+ if (v === undefined)
42
+ throw new Error(`${a} needs a value`);
43
+ return v;
44
+ };
45
+ if (a === "--author")
46
+ authors.push(next());
47
+ else if (a === "--repo")
48
+ repos.push(resolve(next()));
49
+ else if (a === "--since")
50
+ params.since = next();
51
+ else if (a === "--until")
52
+ params.until = next();
53
+ else if (a === "--sample")
54
+ params.sample = Number(next());
55
+ else if (a === "--depth")
56
+ params.depth = Number(next());
57
+ else if (a === "--paths")
58
+ params.paths = true;
59
+ else if (a === "--emails")
60
+ params.emails = true;
61
+ else if (a === "--narrate")
62
+ doNarrate = true;
63
+ else if (a === "--out")
64
+ out = next();
65
+ else if (a === "--json")
66
+ json = true;
67
+ else if (a === "-h" || a === "--help") {
68
+ console.log(HELP);
69
+ process.exit(0);
70
+ }
71
+ else
72
+ throw new Error(`unknown option ${a} (see --help)`);
73
+ }
74
+ if (authors.length)
75
+ params.author = authors;
76
+ if (!repos.length)
77
+ repos.push(process.cwd());
78
+ return { params, repos, out, json, doNarrate, verifyFile };
79
+ }
80
+ async function main() {
81
+ const { params, repos, out, json, doNarrate, verifyFile } = parse(process.argv.slice(2));
82
+ if (verifyFile) {
83
+ const report = JSON.parse(await readFile(verifyFile, "utf8"));
84
+ const result = await verifyReport(report, repos);
85
+ for (const h of result.headMoved)
86
+ console.log(`HEAD moved: ${h}`);
87
+ for (const r of result.rows)
88
+ if (!r.match)
89
+ console.log(`mismatch ${r.repo}/${r.figure}\n report: ${r.expected}\n repository: ${r.actual}`);
90
+ console.log(result.ok ? "all figures reproduce" : `${result.rows.filter((r) => !r.match).length} figures differ`);
91
+ process.exit(result.ok ? 0 : 1);
92
+ }
93
+ const version = createRequire(import.meta.url)("../../package.json").version;
94
+ const repositories = [];
95
+ for (const dir of repos)
96
+ repositories.push(await analyseRepo(dir, params));
97
+ const report = buildReport(repositories, params, { version, generatedAt: new Date().toISOString() });
98
+ let narrative;
99
+ if (doNarrate) {
100
+ const url = process.env.WORKPROOF_API_URL;
101
+ const key = process.env.WORKPROOF_API_KEY;
102
+ const model = process.env.WORKPROOF_MODEL;
103
+ if (!url || !key || !model)
104
+ throw new Error("--narrate needs WORKPROOF_API_URL, WORKPROOF_API_KEY and WORKPROOF_MODEL");
105
+ narrative = await narrate(report, { url, key, model });
106
+ }
107
+ if (json) {
108
+ console.log(JSON.stringify(report, null, 2));
109
+ return;
110
+ }
111
+ await writeFile(`${out}.json`, JSON.stringify(report, null, 2));
112
+ await writeFile(`${out}.md`, renderMarkdown(report, narrative));
113
+ console.log(`wrote ${out}.md and ${out}.json`);
114
+ }
115
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
116
+ if (entry === import.meta.url || entry.endsWith("/workproof")) {
117
+ main().catch((err) => {
118
+ console.error(err instanceof Error ? err.message : String(err));
119
+ process.exit(1);
120
+ });
121
+ }
@@ -0,0 +1,15 @@
1
+ import type { Commit, Tag } from "../git.js";
2
+ import type { Figure, Identity } from "./types.js";
3
+ /** ISO 8601 week key, e.g. 2026-W02. */
4
+ export declare function isoWeek(d: Date): string;
5
+ export declare function cadence(commits: Commit[], tags: Tag[], id: Identity, window: {
6
+ first: string;
7
+ last: string;
8
+ }): Figure<{
9
+ activeWeeks: number;
10
+ weeksInTenure: number;
11
+ commitsPerActiveWeek: number;
12
+ longestStreakWeeks: number;
13
+ tagsInTenure: number;
14
+ authorTags: number;
15
+ }>;
@@ -0,0 +1,59 @@
1
+ import { isMine } from "./identity.js";
2
+ /** ISO 8601 week key, e.g. 2026-W02. */
3
+ export function isoWeek(d) {
4
+ const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
5
+ const dayNum = t.getUTCDay() || 7;
6
+ t.setUTCDate(t.getUTCDate() + 4 - dayNum);
7
+ const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
8
+ const week = Math.ceil(((t.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
9
+ return `${t.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
10
+ }
11
+ function weeksBetween(first, last) {
12
+ const out = [];
13
+ const d = new Date(first + "T00:00:00Z");
14
+ const end = new Date(last + "T00:00:00Z");
15
+ while (d <= end) {
16
+ const w = isoWeek(d);
17
+ if (out[out.length - 1] !== w)
18
+ out.push(w);
19
+ d.setUTCDate(d.getUTCDate() + 1);
20
+ }
21
+ return out;
22
+ }
23
+ export function cadence(commits, tags, id, window) {
24
+ const mine = commits.filter((c) => isMine(c, id) && c.parents <= 1);
25
+ const perWeek = new Map();
26
+ for (const c of mine)
27
+ perWeek.set(isoWeek(c.date), (perWeek.get(isoWeek(c.date)) ?? 0) + 1);
28
+ const weeks = weeksBetween(window.first, window.last);
29
+ let streak = 0;
30
+ let longest = 0;
31
+ for (const w of weeks) {
32
+ if (perWeek.has(w)) {
33
+ streak++;
34
+ longest = Math.max(longest, streak);
35
+ }
36
+ else
37
+ streak = 0;
38
+ }
39
+ const activeWeeks = perWeek.size;
40
+ const start = new Date(window.first + "T00:00:00Z");
41
+ const end = new Date(window.last + "T23:59:59Z");
42
+ const inTenure = tags.filter((t) => t.date >= start && t.date <= end);
43
+ const value = {
44
+ activeWeeks,
45
+ weeksInTenure: weeks.length,
46
+ commitsPerActiveWeek: activeWeeks ? Math.round((mine.length / activeWeeks) * 10) / 10 : 0,
47
+ longestStreakWeeks: longest,
48
+ tagsInTenure: inTenure.length,
49
+ authorTags: inTenure.filter((t) => id.emails.includes(t.email)).length,
50
+ };
51
+ const figure = {
52
+ id: "cadence",
53
+ title: "Cadence",
54
+ value,
55
+ command: "git log --no-merges --format=%aI --author=<identity>; ISO weeks; git for-each-ref refs/tags with creatordate",
56
+ limits: ["A week with one commit and a week with forty both count as active.", "Tags are releases only if the project tags releases."],
57
+ };
58
+ return figure;
59
+ }
@@ -0,0 +1,15 @@
1
+ import type { Commit } from "../git.js";
2
+ import type { Figure, Identity } from "./types.js";
3
+ export declare function tenure(commits: Commit[], id: Identity, override: {
4
+ since?: string;
5
+ until?: string;
6
+ }): Figure<{
7
+ first: string;
8
+ last: string;
9
+ days: number;
10
+ }>;
11
+ export declare function commitShare(commits: Commit[], id: Identity): Figure<{
12
+ author: number;
13
+ total: number;
14
+ share: number;
15
+ }>;
@@ -0,0 +1,30 @@
1
+ import { isMine } from "./identity.js";
2
+ const day = (d) => d.toISOString().slice(0, 10);
3
+ export function tenure(commits, id, override) {
4
+ const mine = commits.filter((c) => isMine(c, id)).map((c) => c.date).sort((a, b) => a.getTime() - b.getTime());
5
+ const first = override.since ? new Date(override.since) : mine[0];
6
+ const last = override.until ? new Date(override.until) : mine[mine.length - 1];
7
+ const days = Math.round((last.getTime() - first.getTime()) / 86400000) + 1;
8
+ return {
9
+ id: "tenure",
10
+ title: "Tenure window",
11
+ value: { first: day(first), last: day(last), days },
12
+ command: override.since || override.until ? "--since/--until as given" : "git log --format=%aI --author=<identity>; first and last commit dates",
13
+ limits: ["Tenure is measured from commits, so work before the first commit or after the last is invisible."],
14
+ };
15
+ }
16
+ export function commitShare(commits, id) {
17
+ const nonMerge = commits.filter((c) => c.parents <= 1);
18
+ const author = nonMerge.filter((c) => isMine(c, id)).length;
19
+ const total = nonMerge.length;
20
+ return {
21
+ id: "commitShare",
22
+ title: "Share of commits in tenure",
23
+ value: { author, total, share: total ? author / total : 0 },
24
+ command: "git log --no-merges --format=%aE --since=<first> --until=<last>; count by author over all",
25
+ limits: [
26
+ "Commits measure activity, not what survived. One commit can be a typo or a subsystem.",
27
+ "Squash-merged branches count once regardless of size.",
28
+ ],
29
+ };
30
+ }
@@ -0,0 +1,28 @@
1
+ import type { Commit } from "../git.js";
2
+ import type { Figure, Identity } from "./types.js";
3
+ export declare function languageOf(path: string): string | null;
4
+ export interface OwnedDirectory {
5
+ path: string;
6
+ author: number;
7
+ total: number;
8
+ share: number;
9
+ }
10
+ export declare function footprint(commits: Commit[], id: Identity, opts: {
11
+ depth: number;
12
+ threshold: number;
13
+ minCommits: number;
14
+ }): Figure<{
15
+ filesTouched: number;
16
+ ownedDirectories: OwnedDirectory[];
17
+ languages: {
18
+ language: string;
19
+ lines: number;
20
+ share: number;
21
+ }[];
22
+ }>;
23
+ export declare function testsAndDocs(commits: Commit[], id: Identity): Figure<{
24
+ testChangesAuthor: number;
25
+ testChangesTotal: number;
26
+ testShare: number;
27
+ docsAuthored: number;
28
+ }>;
@@ -0,0 +1,95 @@
1
+ import { isMine } from "./identity.js";
2
+ const LANG = {
3
+ ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
4
+ py: "Python", cs: "C#", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", swift: "Swift", rb: "Ruby", php: "PHP",
5
+ c: "C", h: "C", cpp: "C++", hpp: "C++", css: "CSS", scss: "CSS", html: "HTML", vue: "Vue", svelte: "Svelte",
6
+ sql: "SQL", sh: "Shell", yml: "YAML", yaml: "YAML", json: "JSON", md: "Markdown", mdx: "Markdown", tf: "Terraform", dart: "Dart",
7
+ };
8
+ export function languageOf(path) {
9
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
10
+ return path.includes(".") ? LANG[ext] ?? null : null;
11
+ }
12
+ const isTest = (p) => /(^|\/)(__tests__|tests?|spec|e2e)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
13
+ const isDoc = (p) => /\.(md|mdx|rst)$/i.test(p);
14
+ function dirAt(path, depth) {
15
+ const parts = path.split("/");
16
+ if (parts.length <= 1)
17
+ return null;
18
+ return parts.slice(0, Math.min(depth, parts.length - 1)).join("/");
19
+ }
20
+ export function footprint(commits, id, opts) {
21
+ const nonMerge = commits.filter((c) => c.parents <= 1);
22
+ const touched = new Set();
23
+ const dirs = new Map();
24
+ const langLines = new Map();
25
+ for (const c of nonMerge) {
26
+ const mine = isMine(c, id);
27
+ const seenDirs = new Set();
28
+ for (const f of c.files) {
29
+ if (mine) {
30
+ touched.add(f.path);
31
+ const lang = languageOf(f.path);
32
+ if (lang && f.added !== null)
33
+ langLines.set(lang, (langLines.get(lang) ?? 0) + f.added);
34
+ }
35
+ const d = dirAt(f.path, opts.depth);
36
+ if (d)
37
+ seenDirs.add(d);
38
+ }
39
+ for (const d of seenDirs) {
40
+ const e = dirs.get(d) ?? { author: 0, total: 0 };
41
+ e.total++;
42
+ if (mine)
43
+ e.author++;
44
+ dirs.set(d, e);
45
+ }
46
+ }
47
+ const ownedDirectories = [...dirs.entries()]
48
+ .map(([path, e]) => ({ path, author: e.author, total: e.total, share: e.total ? e.author / e.total : 0 }))
49
+ .filter((d) => d.total >= opts.minCommits && d.share >= opts.threshold)
50
+ .sort((a, b) => b.share - a.share || b.total - a.total);
51
+ const totalLines = [...langLines.values()].reduce((s, n) => s + n, 0);
52
+ const languages = [...langLines.entries()]
53
+ .map(([language, lines]) => ({ language, lines, share: totalLines ? lines / totalLines : 0 }))
54
+ .sort((a, b) => b.lines - a.lines)
55
+ .slice(0, 8);
56
+ const value = { filesTouched: touched.size, ownedDirectories, languages };
57
+ const figure = {
58
+ id: "footprint",
59
+ title: "Footprint",
60
+ value,
61
+ command: `git log --no-merges --numstat -M; directories at depth ${opts.depth} where the author's commit share is at least ${Math.round(opts.threshold * 100)}% over at least ${opts.minCommits} commits; languages by lines added, extension map`,
62
+ limits: [
63
+ "Lines added include generated and vendored files unless they were excluded upstream.",
64
+ "A directory owned by commit count may still contain other people's surviving code; see the blame figure.",
65
+ ],
66
+ };
67
+ return figure;
68
+ }
69
+ export function testsAndDocs(commits, id) {
70
+ const nonMerge = commits.filter((c) => c.parents <= 1);
71
+ let testChangesAuthor = 0;
72
+ let testChangesTotal = 0;
73
+ const docs = new Set();
74
+ for (const c of nonMerge) {
75
+ const mine = isMine(c, id);
76
+ for (const f of c.files) {
77
+ if (isTest(f.path)) {
78
+ testChangesTotal++;
79
+ if (mine)
80
+ testChangesAuthor++;
81
+ }
82
+ if (mine && isDoc(f.path))
83
+ docs.add(f.path);
84
+ }
85
+ }
86
+ const value = { testChangesAuthor, testChangesTotal, testShare: testChangesTotal ? testChangesAuthor / testChangesTotal : 0, docsAuthored: docs.size };
87
+ const figure = {
88
+ id: "testsAndDocs",
89
+ title: "Tests and documentation",
90
+ value,
91
+ command: "git log --no-merges --numstat; test paths match __tests__/, test/, tests/, spec/, e2e/ or *.test.* / *.spec.*; docs are .md, .mdx, .rst",
92
+ limits: ["Test file changes are counted, not test cases or coverage.", "A README edit and a design document count the same."],
93
+ };
94
+ return figure;
95
+ }
@@ -0,0 +1,9 @@
1
+ import type { Commit } from "../git.js";
2
+ import type { Identity } from "./types.js";
3
+ /**
4
+ * Resolve the author to report on. `author` entries match mailmapped emails or
5
+ * names, case-insensitively; with none given, the repository's configured
6
+ * user.email is used.
7
+ */
8
+ export declare function resolveIdentity(commits: Commit[], author: string[] | undefined, cwd: string): Promise<Identity>;
9
+ export declare const isMine: (c: Commit, id: Identity) => boolean;
@@ -0,0 +1,23 @@
1
+ import { configuredEmail } from "../git.js";
2
+ /**
3
+ * Resolve the author to report on. `author` entries match mailmapped emails or
4
+ * names, case-insensitively; with none given, the repository's configured
5
+ * user.email is used.
6
+ */
7
+ export async function resolveIdentity(commits, author, cwd) {
8
+ const wanted = (author && author.length ? author : [await configuredEmail(cwd)]).map((a) => a.toLowerCase()).filter(Boolean);
9
+ if (!wanted.length)
10
+ throw new Error("no author given and git config user.email is empty; pass --author");
11
+ const emails = new Set();
12
+ const names = new Set();
13
+ for (const c of commits) {
14
+ if (wanted.includes(c.email) || wanted.includes(c.name.toLowerCase())) {
15
+ emails.add(c.email);
16
+ names.add(c.name);
17
+ }
18
+ }
19
+ if (!emails.size)
20
+ throw new Error(`no commits by ${wanted.join(", ")} in this repository`);
21
+ return { emails: [...emails].sort(), names: [...names].sort() };
22
+ }
23
+ export const isMine = (c, id) => id.emails.includes(c.email);
@@ -0,0 +1,12 @@
1
+ import type { Figure, Identity } from "./types.js";
2
+ export declare function survivingLines(cwd: string, id: Identity, opts: {
3
+ sample: number;
4
+ version: string;
5
+ }): Promise<Figure<{
6
+ lines: number;
7
+ linesAttributed: number;
8
+ share: number;
9
+ filesSampled: number;
10
+ filesTotal: number;
11
+ sample: number;
12
+ }>>;
@@ -0,0 +1,27 @@
1
+ // surviving-lines ships plain ESM JavaScript without type declarations.
2
+ // @ts-ignore
3
+ import { analyse, parseArgs } from "surviving-lines/bin/surviving-lines.js";
4
+ export async function survivingLines(cwd, id, opts) {
5
+ const result = await analyse(parseArgs(["--cwd", cwd, "--sample", String(opts.sample)]));
6
+ const mine = result.authors.filter((a) => id.emails.includes(a.mail));
7
+ const lines = mine.reduce((s, a) => s + a.lines, 0);
8
+ const value = {
9
+ lines,
10
+ linesAttributed: result.sample.linesAttributed,
11
+ share: result.sample.linesAttributed ? lines / result.sample.linesAttributed : 0,
12
+ filesSampled: result.sample.filesSampled,
13
+ filesTotal: result.sample.filesTotal,
14
+ sample: opts.sample,
15
+ };
16
+ const figure = {
17
+ id: "survivingLines",
18
+ title: "Surviving lines at HEAD",
19
+ value,
20
+ command: `surviving-lines ${opts.version}: git blame -w -M --line-porcelain over a deterministic 1-in-${opts.sample} file sample (FNV-1a on path)`,
21
+ limits: [
22
+ "Survivorship, not merit: code deleted on purpose counts for nobody.",
23
+ "Whitespace and moved lines keep their original author; copied lines do not unless --copies is used upstream.",
24
+ ],
25
+ };
26
+ return figure;
27
+ }
@@ -0,0 +1,13 @@
1
+ export interface Figure<T> {
2
+ id: string;
3
+ title: string;
4
+ value: T;
5
+ /** The git command (or method) that produced the value, so a reader can rerun it. */
6
+ command: string;
7
+ /** What the figure cannot show. Printed under every figure. */
8
+ limits: string[];
9
+ }
10
+ export interface Identity {
11
+ emails: string[];
12
+ names: string[];
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ export declare function git(args: string[], cwd: string): Promise<string>;
2
+ export interface FileChange {
3
+ path: string;
4
+ added: number | null;
5
+ deleted: number | null;
6
+ }
7
+ export interface Commit {
8
+ sha: string;
9
+ email: string;
10
+ name: string;
11
+ date: Date;
12
+ parents: number;
13
+ files: FileChange[];
14
+ }
15
+ /** All commits reachable from HEAD, newest first, with per-file numstat. One git call. */
16
+ export declare function listCommits(cwd: string, opts: {
17
+ since?: string;
18
+ until?: string;
19
+ }): Promise<Commit[]>;
20
+ export interface Tag {
21
+ name: string;
22
+ date: Date;
23
+ email: string;
24
+ }
25
+ export declare function listTags(cwd: string): Promise<Tag[]>;
26
+ export declare const rootCommit: (cwd: string) => Promise<string>;
27
+ export declare const headSha: (cwd: string) => Promise<string>;
28
+ export declare function remoteUrl(cwd: string): Promise<string>;
29
+ export declare function configuredEmail(cwd: string): Promise<string>;
@@ -0,0 +1,76 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileP = promisify(execFile);
4
+ export async function git(args, cwd) {
5
+ const { stdout } = await execFileP("git", args, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 512 });
6
+ return stdout;
7
+ }
8
+ /** All commits reachable from HEAD, newest first, with per-file numstat. One git call. */
9
+ export async function listCommits(cwd, opts) {
10
+ const args = ["log", "--numstat", "--format=%x1e%H%x1f%aE%x1f%aN%x1f%aI%x1f%P", "-M"];
11
+ if (opts.since)
12
+ args.push(`--since=${opts.since}`);
13
+ if (opts.until)
14
+ args.push(`--until=${opts.until}`);
15
+ const out = await git(args, cwd);
16
+ const commits = [];
17
+ for (const block of out.split("\x1e")) {
18
+ if (!block.trim())
19
+ continue;
20
+ const [header, ...rest] = block.split("\n");
21
+ const [sha, email, name, iso, parents] = header.split("\x1f");
22
+ const files = [];
23
+ for (const line of rest) {
24
+ if (!line.trim())
25
+ continue;
26
+ const [a, d, ...pathParts] = line.split("\t");
27
+ let path = pathParts.join("\t");
28
+ // rename entries look like "old => new" or "dir/{old => new}/file"
29
+ const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
30
+ if (brace)
31
+ path = `${brace[1]}${brace[3]}${brace[4]}`;
32
+ else if (path.includes(" => "))
33
+ path = path.split(" => ")[1];
34
+ files.push({ path, added: a === "-" ? null : Number(a), deleted: d === "-" ? null : Number(d) });
35
+ }
36
+ commits.push({
37
+ sha: sha,
38
+ email: email.toLowerCase(),
39
+ name: name,
40
+ date: new Date(iso),
41
+ parents: parents ? parents.trim().split(" ").filter(Boolean).length : 0,
42
+ files,
43
+ });
44
+ }
45
+ return commits;
46
+ }
47
+ export async function listTags(cwd) {
48
+ const out = await git(["for-each-ref", "--format=%(refname:short)%09%(creatordate:iso-strict)%09%(taggeremail)%09%(*authoremail)%09%(authoremail)", "refs/tags"], cwd);
49
+ const tags = [];
50
+ for (const line of out.split("\n")) {
51
+ if (!line.trim())
52
+ continue;
53
+ const [name, iso, tagger, tagged, direct] = line.split("\t");
54
+ const email = (tagger || tagged || direct || "").replace(/^<|>$/g, "").toLowerCase();
55
+ tags.push({ name: name, date: new Date(iso), email });
56
+ }
57
+ return tags.sort((a, b) => a.date.getTime() - b.date.getTime());
58
+ }
59
+ export const rootCommit = async (cwd) => (await git(["rev-list", "--max-parents=0", "HEAD"], cwd)).trim().split("\n").pop();
60
+ export const headSha = async (cwd) => (await git(["rev-parse", "HEAD"], cwd)).trim();
61
+ export async function remoteUrl(cwd) {
62
+ try {
63
+ return (await git(["config", "--get", "remote.origin.url"], cwd)).trim();
64
+ }
65
+ catch {
66
+ return "";
67
+ }
68
+ }
69
+ export async function configuredEmail(cwd) {
70
+ try {
71
+ return (await git(["config", "--get", "user.email"], cwd)).trim().toLowerCase();
72
+ }
73
+ catch {
74
+ return "";
75
+ }
76
+ }
@@ -0,0 +1,8 @@
1
+ export { analyseRepo, fingerprint } from "./analyse.js";
2
+ export type { Params, RepoReport } from "./analyse.js";
3
+ export { buildReport, renderMarkdown } from "./report.js";
4
+ export type { Report } from "./report.js";
5
+ export { verifyReport } from "./verify.js";
6
+ export type { VerifyRow } from "./verify.js";
7
+ export { narrate } from "./narrate.js";
8
+ export type { Figure, Identity } from "./figures/types.js";
@@ -0,0 +1,4 @@
1
+ export { analyseRepo, fingerprint } from "./analyse.js";
2
+ export { buildReport, renderMarkdown } from "./report.js";
3
+ export { verifyReport } from "./verify.js";
4
+ export { narrate } from "./narrate.js";
@@ -0,0 +1,10 @@
1
+ import type { Report } from "./report.js";
2
+ /**
3
+ * Ask a model for one sober paragraph built only from the figures. Nothing
4
+ * but figure ids, titles and values leaves the machine: no paths, no code.
5
+ */
6
+ export declare function narrate(report: Report, env: {
7
+ url: string;
8
+ key: string;
9
+ model: string;
10
+ }, fetchImpl?: typeof fetch): Promise<string>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ask a model for one sober paragraph built only from the figures. Nothing
3
+ * but figure ids, titles and values leaves the machine: no paths, no code.
4
+ */
5
+ export async function narrate(report, env, fetchImpl = fetch) {
6
+ const figures = report.repositories.map((r) => ({ repository: r.name, figures: r.figures.map((f) => ({ id: f.id, title: f.title, value: f.value })) }));
7
+ const prompt = `You are writing a short, sober paragraph for an engineering résumé. Use only the numbers below; add nothing, round nothing up, and treat "commit share" and "surviving lines" as separate things. No adjectives like "impressive". Figures:\n${JSON.stringify(figures)}`;
8
+ const anthropic = /anthropic\.com/.test(env.url);
9
+ const body = anthropic
10
+ ? { model: env.model, max_tokens: 400, messages: [{ role: "user", content: prompt }] }
11
+ : { model: env.model, messages: [{ role: "user", content: prompt }], temperature: 0.2 };
12
+ const headers = { "content-type": "application/json" };
13
+ if (anthropic) {
14
+ headers["x-api-key"] = env.key;
15
+ headers["anthropic-version"] = "2023-06-01";
16
+ }
17
+ else
18
+ headers.authorization = `Bearer ${env.key}`;
19
+ const res = await fetchImpl(env.url, { method: "POST", headers, body: JSON.stringify(body) });
20
+ if (!res.ok)
21
+ throw new Error(`narrative request failed: ${res.status} ${await res.text()}`);
22
+ const data = (await res.json());
23
+ const text = anthropic ? data.content?.[0]?.text : data.choices?.[0]?.message?.content;
24
+ if (typeof text !== "string")
25
+ throw new Error("narrative response had no text");
26
+ return text;
27
+ }
@@ -0,0 +1,15 @@
1
+ import type { Params, RepoReport } from "./analyse.js";
2
+ export interface Report {
3
+ tool: "workproof";
4
+ version: string;
5
+ generatedAt: string;
6
+ params: Params;
7
+ repositories: RepoReport[];
8
+ /** sha256 of the canonical JSON of { params, repositories }. */
9
+ hash: string;
10
+ }
11
+ export declare function buildReport(repositories: RepoReport[], params: Params, meta: {
12
+ version: string;
13
+ generatedAt: string;
14
+ }): Report;
15
+ export declare function renderMarkdown(report: Report, narrative?: string): string;
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ const canonical = (v) => JSON.stringify(v, (_k, val) => val && typeof val === "object" && !Array.isArray(val)
3
+ ? Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]]))
4
+ : val);
5
+ /** Without --emails, an author given as an email address is not echoed into the report. */
6
+ function publicParams(params) {
7
+ if (params.emails || !params.author)
8
+ return params;
9
+ return { ...params, author: params.author.map((a) => (a.includes("@") ? "(email hidden)" : a)) };
10
+ }
11
+ export function buildReport(repositories, params, meta) {
12
+ const shown = publicParams(params);
13
+ const hash = createHash("sha256").update(canonical({ params: shown, repositories })).digest("hex");
14
+ return { tool: "workproof", version: meta.version, generatedAt: meta.generatedAt, params: shown, repositories, hash };
15
+ }
16
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
17
+ const n = (x) => x.toLocaleString("en-US");
18
+ function figureLines(f) {
19
+ const v = f.value;
20
+ switch (f.id) {
21
+ case "tenure":
22
+ return [`${v.first} to ${v.last} (${n(v.days)} days)`];
23
+ case "commitShare":
24
+ return [`${n(v.author)} of ${n(v.total)} non-merge commits, ${pct(v.share)}`];
25
+ case "cadence":
26
+ return [
27
+ `${v.activeWeeks} active weeks of ${v.weeksInTenure}, ${v.commitsPerActiveWeek} commits per active week, longest streak ${v.longestStreakWeeks} weeks`,
28
+ `${v.authorTags} of ${v.tagsInTenure} release tags in tenure`,
29
+ ];
30
+ case "footprint": {
31
+ const hidden = v.ownedDirectories.length > 0 && String(v.ownedDirectories[0].path).startsWith("(hidden");
32
+ return [
33
+ `${n(v.filesTouched)} files touched`,
34
+ `${v.ownedDirectories.length} directories with a commit share at or above the threshold${hidden ? " (paths hidden; run with --paths)" : ""}`,
35
+ ...(hidden ? [] : v.ownedDirectories.slice(0, 12).map((d) => ` ${d.path}: ${d.author} of ${d.total} commits, ${pct(d.share)}`)),
36
+ "languages by lines added: " + (v.languages.length ? v.languages.map((l) => `${l.language} ${pct(l.share)}`).join(", ") : "none recognised"),
37
+ ];
38
+ }
39
+ case "testsAndDocs":
40
+ return [`${n(v.testChangesAuthor)} of ${n(v.testChangesTotal)} test-file changes, ${pct(v.testShare)}`, `${n(v.docsAuthored)} documents authored`];
41
+ case "survivingLines":
42
+ return [`${n(v.lines)} of ${n(v.linesAttributed)} surviving lines, ${pct(v.share)} (files ${v.filesSampled}/${v.filesTotal}, sample 1 in ${v.sample})`];
43
+ default:
44
+ return [JSON.stringify(v)];
45
+ }
46
+ }
47
+ export function renderMarkdown(report, narrative) {
48
+ const out = [
49
+ `# Engineering report`,
50
+ ``,
51
+ `Generated by workproof ${report.version} on ${report.generatedAt}. Every figure below names the command that produced it and what it cannot show. Verify with \`npx workproof verify <this report>.json\` in the same repository.`,
52
+ ``,
53
+ ];
54
+ for (const repo of report.repositories) {
55
+ 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(", ")})` : ""}`, ``);
56
+ for (const f of repo.figures) {
57
+ out.push(`### ${f.title}`, ``);
58
+ for (const line of figureLines(f))
59
+ out.push(line.startsWith(" ") ? `- ${line.trim()}` : line);
60
+ out.push(``, `How: \`${f.command}\``, ``, `What this cannot show: ${f.limits.join(" ")}`, ``);
61
+ }
62
+ }
63
+ out.push(`## Integrity`, ``, `Report hash \`${report.hash}\` (sha256 of parameters and figures). Repository fingerprints are hashes of the root commit and remote; they identify a repository without naming it.`, ``);
64
+ if (narrative) {
65
+ out.push(`## Generated narrative (not verified)`, ``, `The paragraph below was produced by a language model from the figures above and is not part of the hash.`, ``, narrative.trim(), ``);
66
+ }
67
+ return out.join("\n");
68
+ }
@@ -0,0 +1,14 @@
1
+ import type { Report } from "./report.js";
2
+ export interface VerifyRow {
3
+ repo: string;
4
+ figure: string;
5
+ match: boolean;
6
+ expected: string;
7
+ actual: string;
8
+ }
9
+ /** Recompute every figure in the given repositories and compare with the report. */
10
+ export declare function verifyReport(report: Report, repoDirs: string[]): Promise<{
11
+ ok: boolean;
12
+ rows: VerifyRow[];
13
+ headMoved: string[];
14
+ }>;
@@ -0,0 +1,24 @@
1
+ import { analyseRepo } from "./analyse.js";
2
+ import { headSha } from "./git.js";
3
+ const show = (v) => JSON.stringify(v);
4
+ /** Recompute every figure in the given repositories and compare with the report. */
5
+ export async function verifyReport(report, repoDirs) {
6
+ const rows = [];
7
+ const headMoved = [];
8
+ for (const [i, expected] of report.repositories.entries()) {
9
+ const dir = repoDirs[i] ?? repoDirs[0] ?? process.cwd();
10
+ const head = await headSha(dir);
11
+ if (head !== expected.head)
12
+ headMoved.push(`${expected.name}: report at ${expected.head.slice(0, 12)}, repository at ${head.slice(0, 12)}`);
13
+ // Emails are hidden from the report by default, so resolve the identity from what it does carry.
14
+ const author = expected.identity.emails.length ? expected.identity.emails : expected.identity.names;
15
+ const actual = await analyseRepo(dir, { ...report.params, author });
16
+ for (const f of expected.figures) {
17
+ const a = actual.figures.find((x) => x.id === f.id);
18
+ const e = show(f.value);
19
+ const g = show(a?.value);
20
+ rows.push({ repo: expected.name, figure: f.id, match: e === g, expected: e, actual: g });
21
+ }
22
+ }
23
+ return { ok: rows.every((r) => r.match) && headMoved.length === 0, rows, headMoved };
24
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "workproof",
3
+ "version": "0.1.0",
4
+ "description": "Turn a private git repository into a verifiable engineering report for one author, without showing any code.",
5
+ "type": "module",
6
+ "main": "./dist/src/index.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": { ".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" } },
9
+ "bin": { "workproof": "./dist/src/cli.js" },
10
+ "files": ["dist/src", "README.md", "LICENSE"],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "lint": "tsc -p tsconfig.json --noEmit",
14
+ "test": "npm run build && node --test dist/test/figures.test.js dist/test/report.test.js dist/test/cli.test.js",
15
+ "prepublishOnly": "npm test"
16
+ },
17
+ "engines": { "node": ">=20" },
18
+ "keywords": ["git", "portfolio", "proof-of-work", "career", "blame", "engineering-evidence"],
19
+ "author": "Efe Genc",
20
+ "license": "MIT",
21
+ "repository": { "type": "git", "url": "git+https://github.com/Bubblegunn/workproof.git" },
22
+ "homepage": "https://github.com/Bubblegunn/workproof#readme",
23
+ "dependencies": { "surviving-lines": "^0.1.1" },
24
+ "devDependencies": { "@types/node": "^22.15.0", "typescript": "^5.8.0" }
25
+ }