workproof 0.1.2 → 0.2.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/dist/src/git.js CHANGED
@@ -1,13 +1,37 @@
1
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;
2
+ /**
3
+ * Settings that change what diff and blame attribute, pinned so two machines with different
4
+ * defaults produce the same figures. indentHeuristic became the default in git 2.14 and
5
+ * rename detection in 2.9; a report records the git version it ran under as well.
6
+ */
7
+ export const PINNED_CONFIG = ["-c", "diff.renames=true", "-c", "diff.algorithm=myers", "-c", "diff.indentHeuristic=true", "-c", "core.autocrlf=false"];
8
+ export function git(args, cwd, input) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = execFile("git", [...PINNED_CONFIG, ...args], { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }, (err, stdout) => {
11
+ if (err)
12
+ reject(err);
13
+ else
14
+ resolve(stdout);
15
+ });
16
+ if (input !== undefined)
17
+ child.stdin?.end(input);
18
+ });
19
+ }
20
+ export const gitVersion = async (cwd) => (await git(["--version"], cwd)).trim();
21
+ const RS = "\x1e";
22
+ const US = "\x1f";
23
+ const GS = "\x1d";
24
+ function splitTrailers(field) {
25
+ return (field ?? "").split(GS).map((s) => s.trim()).filter(Boolean);
7
26
  }
8
- /** All commits reachable from HEAD, newest first, with per-file numstat. One git call. */
27
+ /** All commits reachable from HEAD, newest first, with per-file numstat and trailers. One git call. */
9
28
  export async function listCommits(cwd, opts) {
10
- const args = ["log", "--numstat", "--format=%x1e%H%x1f%aE%x1f%aN%x1f%aI%x1f%P", "-M"];
29
+ const args = [
30
+ "log",
31
+ "--numstat",
32
+ "--format=%x1e%H%x1f%aE%x1f%aN%x1f%aI%x1f%P%x1f%(trailers:key=Co-authored-by,valueonly,separator=%x1d)%x1f%(trailers:key=Assisted-by,valueonly,separator=%x1d)",
33
+ "-M",
34
+ ];
11
35
  if (opts.since)
12
36
  args.push(`--since=${opts.since}`);
13
37
  if (opts.until)
@@ -16,24 +40,30 @@ export async function listCommits(cwd, opts) {
16
40
  args.push(`--max-count=${opts.max}`);
17
41
  const out = await git(args, cwd);
18
42
  const commits = [];
19
- for (const block of out.split("\x1e")) {
43
+ for (const block of out.split(RS)) {
20
44
  if (!block.trim())
21
45
  continue;
46
+ // Trailer values may contain newlines only if a trailer does, which git folds; the header ends at the first newline.
22
47
  const [header, ...rest] = block.split("\n");
23
- const [sha, email, name, iso, parents] = header.split("\x1f");
48
+ const [sha, email, name, iso, parents, coAuthorField, assistedField] = header.split(US);
49
+ const coAuthorValues = splitTrailers(coAuthorField);
24
50
  const files = [];
25
51
  for (const line of rest) {
26
52
  if (!line.trim())
27
53
  continue;
28
54
  const [a, d, ...pathParts] = line.split("\t");
29
55
  let path = pathParts.join("\t");
56
+ let from;
30
57
  // rename entries look like "old => new" or "dir/{old => new}/file"
31
58
  const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
32
- if (brace)
59
+ if (brace) {
60
+ from = `${brace[1]}${brace[2]}${brace[4]}`;
33
61
  path = `${brace[1]}${brace[3]}${brace[4]}`;
34
- else if (path.includes(" => "))
35
- path = path.split(" => ")[1];
36
- files.push({ path, added: a === "-" ? null : Number(a), deleted: d === "-" ? null : Number(d) });
62
+ }
63
+ else if (path.includes(" => ")) {
64
+ [from, path] = path.split(" => ");
65
+ }
66
+ files.push({ path, added: a === "-" ? null : Number(a), deleted: d === "-" ? null : Number(d), ...(from ? { from } : {}) });
37
67
  }
38
68
  commits.push({
39
69
  sha: sha,
@@ -42,6 +72,9 @@ export async function listCommits(cwd, opts) {
42
72
  date: new Date(iso),
43
73
  parents: parents ? parents.trim().split(" ").filter(Boolean).length : 0,
44
74
  files,
75
+ coAuthors: coAuthorValues.map((v) => (v.match(/<([^>]+)>/)?.[1] ?? "").toLowerCase()).filter(Boolean),
76
+ coAuthorNames: coAuthorValues.map((v) => v.replace(/\s*<[^>]*>\s*$/, "").trim()).filter(Boolean),
77
+ assistedBy: splitTrailers(assistedField),
45
78
  });
46
79
  }
47
80
  return commits;
@@ -58,6 +91,11 @@ export async function listTags(cwd) {
58
91
  }
59
92
  return tags.sort((a, b) => a.date.getTime() - b.date.getTime());
60
93
  }
94
+ /** Every path in the HEAD tree. */
95
+ export async function listHeadFiles(cwd) {
96
+ const out = await git(["ls-tree", "-r", "-z", "--name-only", "HEAD"], cwd);
97
+ return out.split("\0").filter(Boolean);
98
+ }
61
99
  export const rootCommit = async (cwd) => (await git(["rev-list", "--max-parents=0", "HEAD"], cwd)).trim().split("\n").pop();
62
100
  export const headSha = async (cwd) => (await git(["rev-parse", "HEAD"], cwd)).trim();
63
101
  export async function remoteUrl(cwd) {
@@ -85,6 +123,25 @@ export async function assertRepository(cwd) {
85
123
  throw new Error(`${cwd} is not inside a git repository (use --repo to point at one)`);
86
124
  }
87
125
  }
126
+ /** linguist-generated and linguist-vendored from .gitattributes, for the given paths. */
127
+ export async function checkAttr(cwd, paths) {
128
+ const out = new Map();
129
+ if (!paths.length)
130
+ return out;
131
+ const raw = await git(["check-attr", "-z", "--stdin", "linguist-generated", "linguist-vendored"], cwd, paths.join("\0") + "\0");
132
+ const parts = raw.split("\0");
133
+ for (let i = 0; i + 2 < parts.length; i += 3) {
134
+ const [path, attr, value] = [parts[i], parts[i + 1], parts[i + 2]];
135
+ const entry = out.get(path) ?? { generated: false, vendored: false };
136
+ const set = value === "set" || value === "true";
137
+ if (attr === "linguist-generated")
138
+ entry.generated = set;
139
+ if (attr === "linguist-vendored")
140
+ entry.vendored = set;
141
+ out.set(path, entry);
142
+ }
143
+ return out;
144
+ }
88
145
  export async function configuredEmail(cwd) {
89
146
  try {
90
147
  return (await git(["config", "--get", "user.email"], cwd)).trim().toLowerCase();
@@ -1,8 +1,14 @@
1
- export { analyseRepo, fingerprint } from "./analyse.js";
1
+ export { analyseRepo, fingerprint, newFingerprintKey, publicEmail } from "./analyse.js";
2
2
  export type { Params, RepoReport, AnalyseHooks } from "./analyse.js";
3
- export { buildReport, renderMarkdown } from "./report.js";
3
+ export { buildReport, renderMarkdown, hashOf } from "./report.js";
4
+ export { canonicalize } from "./canonical.js";
5
+ export { validateReport } from "./schema.js";
4
6
  export type { Report } from "./report.js";
5
- export { verifyReport } from "./verify.js";
6
- export type { VerifyRow } from "./verify.js";
7
+ export { verifyReport, checkReport } from "./verify.js";
8
+ export type { VerifyRow, VerifyResult, Integrity } from "./verify.js";
7
9
  export { narrate } from "./narrate.js";
8
10
  export type { Figure, Identity } from "./figures/types.js";
11
+ export { badgeFor } from "./badge.js";
12
+ export type { Badge } from "./badge.js";
13
+ export { statementFor, predicateFor, writeStatement, signLocal, PREDICATE_TYPE, STATEMENT_TYPE } from "./attest.js";
14
+ export type { InTotoStatement, Predicate, DsseEnvelope } from "./attest.js";
package/dist/src/index.js CHANGED
@@ -1,4 +1,8 @@
1
- export { analyseRepo, fingerprint } from "./analyse.js";
2
- export { buildReport, renderMarkdown } from "./report.js";
3
- export { verifyReport } from "./verify.js";
1
+ export { analyseRepo, fingerprint, newFingerprintKey, publicEmail } from "./analyse.js";
2
+ export { buildReport, renderMarkdown, hashOf } from "./report.js";
3
+ export { canonicalize } from "./canonical.js";
4
+ export { validateReport } from "./schema.js";
5
+ export { verifyReport, checkReport } from "./verify.js";
4
6
  export { narrate } from "./narrate.js";
7
+ export { badgeFor } from "./badge.js";
8
+ export { statementFor, predicateFor, writeStatement, signLocal, PREDICATE_TYPE, STATEMENT_TYPE } from "./attest.js";
@@ -1,13 +1,17 @@
1
1
  import type { Params, RepoReport } from "./analyse.js";
2
2
  export interface Report {
3
3
  tool: "workproof";
4
+ /** Shape of the document; check and verify refuse other versions. */
5
+ schemaVersion: 2;
4
6
  version: string;
5
7
  generatedAt: string;
6
8
  params: Params;
7
9
  repositories: RepoReport[];
8
- /** sha256 of the canonical JSON of { params, repositories }. */
10
+ /** sha256 of the RFC 8785 canonical JSON of { params, repositories }. */
9
11
  hash: string;
10
12
  }
13
+ /** The hash a report with these parameters and repositories must carry. */
14
+ export declare const hashOf: (params: unknown, repositories: unknown) => string;
11
15
  export declare function buildReport(repositories: RepoReport[], params: Params, meta: {
12
16
  version: string;
13
17
  generatedAt: string;
@@ -1,20 +1,26 @@
1
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. */
2
+ import { canonicalize } from "./canonical.js";
3
+ import { publicEmail } from "./analyse.js";
4
+ import { plainSummary } from "./summary.js";
5
+ /** 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 the parameters the report may carry: never the fingerprint key, never a GitHub
9
+ * noreply login, and without --emails no author given as an email address.
10
+ */
6
11
  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)) };
12
+ const { fingerprintKey: _key, ...rest } = params;
13
+ if (!rest.author)
14
+ return rest;
15
+ return { ...rest, author: rest.author.map((a) => (a.includes("@") ? (params.emails ? publicEmail(a) : "(email hidden)") : a)) };
10
16
  }
11
17
  export function buildReport(repositories, params, meta) {
12
18
  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 };
19
+ return { tool: "workproof", schemaVersion: 2, version: meta.version, generatedAt: meta.generatedAt, params: shown, repositories, hash: hashOf(shown, repositories) };
15
20
  }
16
21
  const pct = (x) => `${(x * 100).toFixed(1)}%`;
17
22
  const n = (x) => x.toLocaleString("en-US");
23
+ const plural = (x, one, many) => `${n(x)} ${x === 1 ? one : many}`;
18
24
  function figureLines(f) {
19
25
  const v = f.value;
20
26
  switch (f.id) {
@@ -37,9 +43,23 @@ function figureLines(f) {
37
43
  ];
38
44
  }
39
45
  case "testsAndDocs":
40
- return [`${n(v.testChangesAuthor)} of ${n(v.testChangesTotal)} test-file changes, ${pct(v.testShare)}`, `${n(v.docsAuthored)} documents authored`];
46
+ return [`${n(v.testChangesAuthor)} of ${n(v.testChangesTotal)} test-file changes, ${pct(v.testShare)}`, `${n(v.docsCreated)} documents created`];
47
+ case "filesAuthored":
48
+ return [`${n(v.authored)} of ${n(v.total)} files alive at HEAD, ${pct(v.share)} (degree of authorship)`];
49
+ case "majorContributor":
50
+ return [`major contributor in ${n(v.major)} of ${n(v.dirs)} directories (at least ${v.threshold * 100}% of commits)`];
51
+ case "commitSize":
52
+ return [`median ${n(v.median)} lines, 90th percentile ${n(v.p90)}, ${plural(v.huge, "commit", "commits")} over 10,000 lines`];
53
+ case "coAuthored":
54
+ return [`${plural(v.trailerCommits, "commit", "commits")} by others naming the author in a Co-authored-by trailer`];
55
+ case "absenceFactor":
56
+ return [`${n(v.authorsToHalf)} author${v.authorsToHalf === 1 ? "" : "s"} cover half the commits; the author ranks ${n(v.authorRank)} of ${n(v.authors)} by commit count`];
57
+ case "aiAssisted":
58
+ return [`${plural(v.commits, "commit declares", "commits declare")} an AI tool in a trailer, ${pct(v.share)} of the author's commits`];
59
+ case "survivalByCohort":
60
+ return v.length ? v.map((c) => `${c.year}: ${n(c.lines)} lines`) : ["no surviving lines in the sample"];
41
61
  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})`];
62
+ return [`${n(v.lines)} of ${n(v.linesAttributed)} surviving lines, ${pct(v.share)} (files ${v.filesSampled}/${v.filesTotal}, sample 1 in ${v.sample}${v.seed ? `, seed "${v.seed}"` : ""})`];
43
63
  default:
44
64
  return [JSON.stringify(v)];
45
65
  }
@@ -52,7 +72,9 @@ export function renderMarkdown(report, narrative) {
52
72
  ``,
53
73
  ];
54
74
  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(", ")})` : ""}`, ``);
75
+ out.push(`## ${repo.name}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`, ``, repo.excluded.enabled
76
+ ? `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
+ : `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.`, ``);
56
78
  for (const f of repo.figures) {
57
79
  out.push(`### ${f.title}`, ``);
58
80
  for (const line of figureLines(f))
@@ -60,7 +82,7 @@ export function renderMarkdown(report, narrative) {
60
82
  out.push(``, `How: \`${f.command}\``, ``, `What this cannot show: ${f.limits.join(" ")}`, ``);
61
83
  }
62
84
  }
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.`, ``);
85
+ out.push(`## Integrity`, ``, `Report hash \`${report.hash}\` (sha256 over the RFC 8785 canonical JSON of parameters and figures; \`npx workproof check\` recomputes it offline). Repository fingerprints are keyed hashes of the root commit and remote; they identify a repository without naming it, and only someone holding the key printed when the report was made can compare them.`, ``);
64
86
  if (narrative) {
65
87
  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
88
  }
@@ -0,0 +1,4 @@
1
+ type Problem = string;
2
+ /** Returns the list of problems; an empty list means the report has the 0.2 shape. */
3
+ export declare function validateReport(value: unknown): Problem[];
4
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shape check for a report, done by hand so `check` needs no dependency. The same shape
3
+ * is published as JSON Schema in schema/report.schema.json for other tools.
4
+ */
5
+ const hex = (n) => new RegExp(`^[0-9a-f]{${n}}$`);
6
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
7
+ function expect(problems, path, ok, what) {
8
+ if (!ok)
9
+ problems.push(`${path}: expected ${what}`);
10
+ return ok;
11
+ }
12
+ function checkFigure(problems, path, f) {
13
+ if (!expect(problems, path, isObject(f), "object"))
14
+ return;
15
+ const fig = f;
16
+ expect(problems, `${path}.id`, typeof fig.id === "string" && fig.id.length > 0, "non-empty string");
17
+ expect(problems, `${path}.title`, typeof fig.title === "string", "string");
18
+ expect(problems, `${path}.value`, fig.value !== undefined, "a value");
19
+ expect(problems, `${path}.command`, typeof fig.command === "string", "string");
20
+ expect(problems, `${path}.limits`, Array.isArray(fig.limits) && fig.limits.every((l) => typeof l === "string"), "array of strings");
21
+ }
22
+ function checkRepository(problems, path, r) {
23
+ if (!expect(problems, path, isObject(r), "object"))
24
+ return;
25
+ const repo = r;
26
+ expect(problems, `${path}.name`, typeof repo.name === "string", "string");
27
+ expect(problems, `${path}.head`, typeof repo.head === "string" && hex(40).test(repo.head), "40 hex characters");
28
+ expect(problems, `${path}.fingerprint`, typeof repo.fingerprint === "string" && hex(64).test(repo.fingerprint), "64 hex characters");
29
+ if (expect(problems, `${path}.identity`, isObject(repo.identity), "object")) {
30
+ const id = repo.identity;
31
+ expect(problems, `${path}.identity.emails`, Array.isArray(id.emails), "array");
32
+ expect(problems, `${path}.identity.names`, Array.isArray(id.names), "array");
33
+ expect(problems, `${path}.identity.count`, typeof id.count === "number", "number");
34
+ }
35
+ if (expect(problems, `${path}.environment`, isObject(repo.environment), "object")) {
36
+ const env = repo.environment;
37
+ expect(problems, `${path}.environment.git`, typeof env.git === "string", "string");
38
+ expect(problems, `${path}.environment.blame`, Array.isArray(env.blame), "array");
39
+ }
40
+ if (expect(problems, `${path}.excluded`, isObject(repo.excluded), "object")) {
41
+ const ex = repo.excluded;
42
+ expect(problems, `${path}.excluded.botCommits`, typeof ex.botCommits === "number", "number");
43
+ expect(problems, `${path}.excluded.files`, typeof ex.files === "number", "number");
44
+ expect(problems, `${path}.excluded.linesAddedShare`, typeof ex.linesAddedShare === "number", "number");
45
+ }
46
+ if (expect(problems, `${path}.figures`, Array.isArray(repo.figures) && repo.figures.length > 0, "non-empty array")) {
47
+ repo.figures.forEach((f, i) => checkFigure(problems, `${path}.figures[${i}]`, f));
48
+ }
49
+ }
50
+ /** Returns the list of problems; an empty list means the report has the 0.2 shape. */
51
+ export function validateReport(value) {
52
+ const problems = [];
53
+ if (!expect(problems, "report", isObject(value), "object"))
54
+ return problems;
55
+ const r = value;
56
+ expect(problems, "tool", r.tool === "workproof", '"workproof"');
57
+ expect(problems, "schemaVersion", r.schemaVersion === 2, "2");
58
+ expect(problems, "version", typeof r.version === "string", "string");
59
+ expect(problems, "generatedAt", typeof r.generatedAt === "string" && !Number.isNaN(Date.parse(r.generatedAt)), "ISO date");
60
+ expect(problems, "params", isObject(r.params), "object");
61
+ expect(problems, "hash", typeof r.hash === "string" && hex(64).test(r.hash), "64 hex characters");
62
+ if (expect(problems, "repositories", Array.isArray(r.repositories) && r.repositories.length > 0, "non-empty array")) {
63
+ r.repositories.forEach((repo, i) => checkRepository(problems, `repositories[${i}]`, repo));
64
+ }
65
+ return problems;
66
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The report in plain language, for the person who will read it and does not
3
+ * write software: a recruiter, a hiring manager, a caseworker.
4
+ *
5
+ * Built from the figures with no model call, so it is deterministic and adds
6
+ * nothing that is not already in the numbers above it. It states no opinion
7
+ * about quality, keeps the two shares apart, and ends with how to check it.
8
+ * The paragraph is derived from the hashed figures, not part of the hash.
9
+ */
10
+ import type { RepoReport } from "./analyse.js";
11
+ export declare function plainSummary(repo: RepoReport): string;
@@ -0,0 +1,47 @@
1
+ const pct = (x) => `${Math.round(x * 100)}%`;
2
+ const n = (x) => x.toLocaleString("en-US");
3
+ const value = (repo, id) => repo.figures.find((f) => f.id === id)?.value;
4
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
5
+ /** "2026-01-05" to "5 January 2026". Dates only; the report carries no times. */
6
+ function longDate(iso) {
7
+ const [y, m, d] = iso.split("-").map(Number);
8
+ if (!y || !m || !d)
9
+ return iso;
10
+ return `${d} ${MONTHS[m - 1]} ${y}`;
11
+ }
12
+ export function plainSummary(repo) {
13
+ const who = repo.identity.names[0] ?? "This author";
14
+ const tenure = value(repo, "tenure");
15
+ const commits = value(repo, "commitShare");
16
+ const surviving = value(repo, "survivingLines");
17
+ const cadence = value(repo, "cadence");
18
+ const files = value(repo, "filesAuthored");
19
+ const absence = value(repo, "absenceFactor");
20
+ const sentences = [];
21
+ 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.`);
23
+ }
24
+ if (commits && surviving) {
25
+ 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)}).`);
26
+ sentences.push(commits.share >= surviving.share
27
+ ? `The second number is the one that lasts: it counts the work that survived everything written since.`
28
+ : `The second number is the one that lasts, and here it is the higher of the two: their work survived everything written since better than the count of changes suggests.`);
29
+ }
30
+ if (files) {
31
+ sentences.push(`${n(files.authored)} of the ${n(files.total)} files in the project were started by them (${pct(files.share)}).`);
32
+ }
33
+ if (cadence) {
34
+ sentences.push(`They were active in ${n(cadence.activeWeeks)} of the ${n(cadence.weeksInTenure)} weeks in that period.`);
35
+ }
36
+ if (absence && absence.authors > 1) {
37
+ sentences.push(`${n(absence.authors)} people wrote code in this project; by share of surviving lines they rank ${n(absence.authorRank)}.`);
38
+ }
39
+ if (repo.excluded.enabled) {
40
+ sentences.push(`Before any of this was counted, ${n(repo.excluded.botCommits)} automated ${repo.excluded.botCommits === 1 ? "change" : "changes"} and ${n(repo.excluded.files)} machine-written or copied ${repo.excluded.files === 1 ? "file" : "files"} were removed, so none of them inflate the figures.`);
41
+ }
42
+ sentences.push(surviving && surviving.sample > 1
43
+ ? `The line figures come from a fixed sample of one file in ${n(surviving.sample)}, chosen by a rule that anyone re-running this gets the same way.`
44
+ : `Every figure above names the exact command that produced it.`);
45
+ sentences.push(`Anyone with a copy of this project can recompute all of it with \`npx workproof verify\`, and the report will not match if a number was edited.`);
46
+ return sentences.join(" ");
47
+ }
@@ -6,9 +6,35 @@ export interface VerifyRow {
6
6
  expected: string;
7
7
  actual: string;
8
8
  }
9
- /** Recompute every figure in the given repositories and compare with the report. */
10
- export declare function verifyReport(report: Report, repoDirs: string[]): Promise<{
9
+ export interface Integrity {
11
10
  ok: boolean;
11
+ problems: string[];
12
+ hash: {
13
+ stated: string;
14
+ computed: string;
15
+ };
16
+ }
17
+ /**
18
+ * Offline: does the document have the 0.2 shape, and does its content hash to the hash it
19
+ * states? No git, no repository. An edited report fails here before anything is recomputed.
20
+ */
21
+ export declare function checkReport(report: unknown): Integrity;
22
+ export interface VerifyResult {
23
+ ok: boolean;
24
+ integrity: Integrity;
25
+ /** Per repository: whether the fingerprint was compared, and whether it matched. */
26
+ fingerprints: {
27
+ repo: string;
28
+ compared: boolean;
29
+ match: boolean;
30
+ }[];
12
31
  rows: VerifyRow[];
13
32
  headMoved: string[];
14
- }>;
33
+ }
34
+ /**
35
+ * check first, then the repository: fingerprint and HEAD, then every figure recomputed
36
+ * and compared. A fingerprint mismatch is a different repository and stops before figures.
37
+ */
38
+ export declare function verifyReport(report: Report, repoDirs: string[], opts?: {
39
+ fingerprintKey?: string;
40
+ }): Promise<VerifyResult>;
@@ -1,18 +1,49 @@
1
- import { analyseRepo } from "./analyse.js";
2
- import { headSha } from "./git.js";
1
+ import { analyseRepo, fingerprint } from "./analyse.js";
2
+ import { headSha, rootCommit, remoteUrl } from "./git.js";
3
+ import { hashOf } from "./report.js";
4
+ import { validateReport } from "./schema.js";
3
5
  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
+ /**
7
+ * Offline: does the document have the 0.2 shape, and does its content hash to the hash it
8
+ * states? No git, no repository. An edited report fails here before anything is recomputed.
9
+ */
10
+ export function checkReport(report) {
11
+ const problems = validateReport(report);
12
+ const r = report;
13
+ const stated = typeof r?.hash === "string" ? r.hash : "";
14
+ let computed = "";
15
+ if (problems.length === 0) {
16
+ computed = hashOf(r.params, r.repositories);
17
+ if (computed !== stated)
18
+ problems.push(`hash mismatch: report says ${stated}, content hashes to ${computed}`);
19
+ }
20
+ return { ok: problems.length === 0, problems, hash: { stated, computed } };
21
+ }
22
+ /**
23
+ * check first, then the repository: fingerprint and HEAD, then every figure recomputed
24
+ * and compared. A fingerprint mismatch is a different repository and stops before figures.
25
+ */
26
+ export async function verifyReport(report, repoDirs, opts = {}) {
27
+ const integrity = checkReport(report);
6
28
  const rows = [];
7
29
  const headMoved = [];
30
+ const fingerprints = [];
31
+ if (!integrity.ok)
32
+ return { ok: false, integrity, fingerprints, rows, headMoved };
8
33
  for (const [i, expected] of report.repositories.entries()) {
9
34
  const dir = repoDirs[i] ?? repoDirs[0] ?? process.cwd();
35
+ const key = opts.fingerprintKey ?? report.params.fingerprintKey;
36
+ const compared = !expected.fingerprintKeyed || key !== undefined;
37
+ const match = compared ? fingerprint(await rootCommit(dir), await remoteUrl(dir), expected.fingerprintKeyed ? key : undefined) === expected.fingerprint : false;
38
+ fingerprints.push({ repo: expected.name, compared, match });
39
+ if (compared && !match)
40
+ continue;
10
41
  const head = await headSha(dir);
11
42
  if (head !== expected.head)
12
43
  headMoved.push(`${expected.name}: report at ${expected.head.slice(0, 12)}, repository at ${head.slice(0, 12)}`);
13
44
  // Emails are hidden from the report by default, so resolve the identity from what it does carry.
14
45
  const author = expected.identity.emails.length ? expected.identity.emails : expected.identity.names;
15
- const actual = await analyseRepo(dir, { ...report.params, author });
46
+ const actual = await analyseRepo(dir, { ...report.params, author, ...(key ? { fingerprintKey: key } : {}) });
16
47
  for (const f of expected.figures) {
17
48
  const a = actual.figures.find((x) => x.id === f.id);
18
49
  const e = show(f.value);
@@ -20,5 +51,6 @@ export async function verifyReport(report, repoDirs) {
20
51
  rows.push({ repo: expected.name, figure: f.id, match: e === g, expected: e, actual: g });
21
52
  }
22
53
  }
23
- return { ok: rows.every((r) => r.match) && headMoved.length === 0, rows, headMoved };
54
+ const ok = fingerprints.every((f) => !f.compared || f.match) && rows.every((r) => r.match) && headMoved.length === 0;
55
+ return { ok, integrity, fingerprints, rows, headMoved };
24
56
  }
package/package.json CHANGED
@@ -1,25 +1,80 @@
1
1
  {
2
2
  "name": "workproof",
3
- "version": "0.1.2",
4
- "description": "Turn a private git repository into a verifiable engineering report for one author, without showing any code.",
3
+ "version": "0.2.0",
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",
7
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"],
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/src/index.d.ts",
11
+ "import": "./dist/src/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "workproof": "dist/src/cli.js"
16
+ },
17
+ "files": [
18
+ "dist/src",
19
+ "schema",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
11
24
  "scripts": {
12
25
  "build": "tsc -p tsconfig.json",
13
26
  "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"
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",
28
+ "prepublishOnly": "npm test",
29
+ "examples": "npm run build && node examples/basic.mjs",
30
+ "release": "node scripts/release.mjs",
31
+ "release-gate": "npm run build && node scripts/release-gate.mjs",
32
+ "bench:adversarial": "npm run build && node bench/adversarial.mjs"
16
33
  },
17
- "engines": { "node": ">=20" },
18
- "keywords": ["git", "portfolio", "proof-of-work", "career", "blame", "engineering-evidence"],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "keywords": [
38
+ "git",
39
+ "git-blame",
40
+ "engineering-report",
41
+ "verifiable",
42
+ "proof-of-work",
43
+ "portfolio",
44
+ "resume",
45
+ "hiring",
46
+ "career",
47
+ "authorship",
48
+ "code-ownership",
49
+ "surviving-lines",
50
+ "engineering-evidence",
51
+ "in-toto",
52
+ "sigstore",
53
+ "attestation",
54
+ "github-action",
55
+ "cli"
56
+ ],
19
57
  "author": "Efe Genc",
20
58
  "license": "MIT",
21
- "repository": { "type": "git", "url": "git+https://github.com/Bubblegunn/workproof.git" },
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/Bubblegunn/workproof.git"
62
+ },
22
63
  "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" }
64
+ "bugs": {
65
+ "url": "https://github.com/Bubblegunn/workproof/issues"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "provenance": true
70
+ },
71
+ "dependencies": {
72
+ "surviving-lines": "^0.1.1"
73
+ },
74
+ "devDependencies": {
75
+ "@arethetypeswrong/cli": "^0.18.5",
76
+ "@types/node": "^26.4.1",
77
+ "publint": "^0.3.24",
78
+ "typescript": "^7.0.2"
79
+ }
25
80
  }