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.
@@ -1,17 +1,35 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
2
  import { basename } from "node:path";
3
3
  import { createRequire } from "node:module";
4
- import { listCommits, listTags, rootCommit, headSha, remoteUrl, assertRepository } from "./git.js";
4
+ import { access } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { listCommits, listTags, rootCommit, headSha, remoteUrl, assertRepository, gitVersion, checkAttr, listHeadFiles } from "./git.js";
7
+ import { isBot, excludedSet } from "./exclusions.js";
8
+ // surviving-lines ships plain ESM JavaScript without type declarations.
9
+ // @ts-ignore
10
+ import { globToRegExp } from "surviving-lines/bin/surviving-lines.js";
5
11
  import { resolveIdentity } from "./figures/identity.js";
6
12
  import { tenure, commitShare } from "./figures/commits.js";
7
13
  import { cadence } from "./figures/cadence.js";
8
14
  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) {
15
+ import { survivingLines, blameFlags } from "./figures/surviving.js";
16
+ import { filesAuthored, majorContributor, commitSize, coAuthored, absenceFactor, aiAssisted, survivalByCohort } from "./figures/authorship.js";
17
+ /** A fresh 16-byte fingerprint key as hex. Printed once, stored nowhere. */
18
+ export const newFingerprintKey = () => randomBytes(16).toString("hex");
19
+ const NOREPLY = /@users\.noreply\.github\.com$/i;
20
+ /** GitHub noreply addresses carry the login in the local part, so they are never written out. */
21
+ export const publicEmail = (email) => (NOREPLY.test(email) ? "(github noreply)" : email);
22
+ /**
23
+ * Identifies a repository without naming it. Keyed: HMAC-SHA256 under a per-report key of
24
+ * the root commit and the normalised remote, so a reader cannot look a public repository
25
+ * up from its fingerprint. Unkeyed (no key given): plain sha256, kept for callers that
26
+ * want a stable public identifier.
27
+ */
28
+ export function fingerprint(root, remote, key) {
12
29
  let r = remote.trim().toLowerCase().replace(/\.git$/, "");
13
30
  r = r.replace(/^[a-z+]+:\/\//, "").replace(/^git@([^:]+):/, "$1/");
14
- return createHash("sha256").update(`${root}\n${r}`).digest("hex");
31
+ const text = `${root}\n${r}`;
32
+ return key === undefined ? createHash("sha256").update(text).digest("hex") : createHmac("sha256", Buffer.from(key, "hex")).update(text).digest("hex");
15
33
  }
16
34
  const require = createRequire(import.meta.url);
17
35
  const survivingVersion = () => {
@@ -22,19 +40,70 @@ const survivingVersion = () => {
22
40
  return "unknown";
23
41
  }
24
42
  };
43
+ async function ignoreRevsFor(cwd, params) {
44
+ if (params.ignoreRevsFile)
45
+ return params.ignoreRevsFile;
46
+ try {
47
+ await access(join(cwd, ".git-blame-ignore-revs"));
48
+ return ".git-blame-ignore-revs";
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Bot commits out, excluded paths out of every commit's file list; the untouched copy stays for the share. */
55
+ async function applyExclusions(cwd, all, params) {
56
+ const enabled = params.exclusions !== false;
57
+ const extra = (params.exclude ?? []).map((g) => globToRegExp(g));
58
+ const botCommits = enabled ? all.filter(isBot).length : 0;
59
+ const human = enabled ? all.filter((c) => !isBot(c)) : all;
60
+ const headFiles = await listHeadFiles(cwd);
61
+ const paths = new Set(headFiles);
62
+ for (const c of human)
63
+ for (const f of c.files)
64
+ paths.add(f.path);
65
+ const attrs = enabled ? await checkAttr(cwd, [...paths]) : new Map();
66
+ const excluded = excludedSet(paths, attrs, extra);
67
+ if (!enabled)
68
+ for (const p of [...excluded])
69
+ if (!extra.some((re) => re.test(p)))
70
+ excluded.delete(p);
71
+ const commits = human.map((c) => ({ ...c, files: c.files.filter((f) => !excluded.has(f.path)) }));
72
+ return { commits, raw: human, excluded, botCommits, enabled, headFiles: new Set(headFiles.filter((p) => !excluded.has(p))) };
73
+ }
25
74
  export async function analyseRepo(cwd, params, hooks = {}) {
26
75
  const say = hooks.progress ?? (() => { });
27
76
  await assertRepository(cwd);
77
+ const key = params.fingerprintKey ?? newFingerprintKey();
78
+ if (!params.fingerprintKey)
79
+ say(`fingerprint key ${key} (keep it to compare reports; it is not stored)`);
28
80
  say(`${basename(cwd)}: reading history${params.maxCommits ? ` (newest ${params.maxCommits} commits)` : ""}...`);
29
- const all = await listCommits(cwd, params.maxCommits ? { max: params.maxCommits } : {});
30
- say(`${basename(cwd)}: ${all.length.toLocaleString("en-US")} commits read`);
81
+ const everything = await listCommits(cwd, params.maxCommits ? { max: params.maxCommits } : {});
82
+ say(`${basename(cwd)}: ${everything.length.toLocaleString("en-US")} commits read`);
83
+ const ex = await applyExclusions(cwd, everything, params);
84
+ const all = ex.commits;
31
85
  const id = await resolveIdentity(all, params.author, cwd);
32
86
  const t = tenure(all, id, { ...(params.since ? { since: params.since } : {}), ...(params.until ? { until: params.until } : {}) });
33
87
  const start = new Date(t.value.first + "T00:00:00Z");
34
88
  const end = new Date(t.value.last + "T23:59:59Z");
35
89
  const inTenure = all.filter((c) => c.date >= start && c.date <= end);
90
+ let addedAll = 0;
91
+ let addedExcluded = 0;
92
+ for (const c of ex.raw) {
93
+ if (c.date < start || c.date > end || c.parents > 1)
94
+ continue;
95
+ for (const f of c.files) {
96
+ if (f.added === null)
97
+ continue;
98
+ addedAll += f.added;
99
+ if (ex.excluded.has(f.path))
100
+ addedExcluded += f.added;
101
+ }
102
+ }
36
103
  const tags = await listTags(cwd);
37
104
  const sample = params.sample ?? (all.reduce((n, c) => n + c.files.length, 0) > 50000 ? 7 : 1);
105
+ const ignoreRevs = await ignoreRevsFor(cwd, params);
106
+ const blame = blameFlags(params.copies ?? false, ignoreRevs);
38
107
  const fp = footprint(inTenure, id, { depth: params.depth, threshold: params.threshold, minCommits: params.minCommits });
39
108
  if (!params.paths) {
40
109
  fp.value = { ...fp.value, ownedDirectories: fp.value.ownedDirectories.map((d) => ({ ...d, path: "(hidden; run with --paths)" })) };
@@ -45,16 +114,33 @@ export async function analyseRepo(cwd, params, hooks = {}) {
45
114
  cadence(inTenure, tags, id, { first: t.value.first, last: t.value.last }),
46
115
  fp,
47
116
  testsAndDocs(inTenure, id),
117
+ filesAuthored(all, id, ex.headFiles),
118
+ majorContributor(inTenure, id, params.depth),
119
+ commitSize(inTenure, id),
120
+ coAuthored(inTenure, id),
121
+ absenceFactor(inTenure, id),
122
+ aiAssisted(inTenure, id),
48
123
  ];
49
124
  say(`${basename(cwd)}: blaming files (1 in ${sample} sample)...`);
50
- const surviving = await survivingLines(cwd, id, { sample, version: survivingVersion() });
125
+ const surviving = await survivingLines(cwd, id, {
126
+ sample,
127
+ seed: params.seed ?? "",
128
+ exclude: params.exclude ?? [],
129
+ copies: params.copies ?? false,
130
+ ignoreRevsFile: ignoreRevs,
131
+ excluded: ex.excluded,
132
+ version: survivingVersion(),
133
+ });
51
134
  say(`${basename(cwd)}: blamed ${surviving.value.filesSampled} of ${surviving.value.filesTotal} files`);
52
- figures.push(surviving);
135
+ figures.push(surviving, survivalByCohort(surviving.value.byYear, sample));
53
136
  return {
54
137
  name: basename(cwd),
55
138
  head: await headSha(cwd),
56
- fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd)),
57
- identity: { emails: params.emails ? id.emails : [], names: id.names, count: id.emails.length },
139
+ fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd), key),
140
+ fingerprintKeyed: true,
141
+ identity: { emails: params.emails ? [...new Set(id.emails.map(publicEmail))] : [], names: id.names, count: id.emails.length },
142
+ environment: { git: await gitVersion(cwd), blame, ignoreRevs, seed: params.seed ?? "" },
143
+ excluded: { botCommits: ex.botCommits, files: ex.excluded.size, linesAddedShare: addedAll ? addedExcluded / addedAll : 0, enabled: ex.enabled },
58
144
  figures,
59
145
  };
60
146
  }
@@ -0,0 +1,62 @@
1
+ import type { Report } from "./report.js";
2
+ export declare const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
3
+ export declare const PREDICATE_TYPE = "https://workproof.dev/attestation/v1";
4
+ export declare const PAYLOAD_TYPE = "application/vnd.in-toto+json";
5
+ export interface InTotoStatement {
6
+ _type: typeof STATEMENT_TYPE;
7
+ subject: {
8
+ name: string;
9
+ digest: {
10
+ sha256: string;
11
+ };
12
+ }[];
13
+ predicateType: typeof PREDICATE_TYPE;
14
+ predicate: Predicate;
15
+ }
16
+ export interface Predicate {
17
+ tool: {
18
+ name: "workproof";
19
+ version: string;
20
+ };
21
+ generatedAt: string;
22
+ params: Record<string, unknown>;
23
+ repositories: {
24
+ head: string;
25
+ fingerprint: string;
26
+ fingerprintKeyed: boolean;
27
+ identity: {
28
+ names: string[];
29
+ };
30
+ environment: Report["repositories"][number]["environment"];
31
+ excluded: Report["repositories"][number]["excluded"];
32
+ }[];
33
+ }
34
+ /**
35
+ * The predicate says what was measured and under which environment, nothing more: no
36
+ * figures (they are behind the subject digest), no remote URL, no paths of any kind.
37
+ */
38
+ export declare function predicateFor(report: Report): Predicate;
39
+ /** An in-toto v1 Statement whose subject is the report's own hash. */
40
+ export declare function statementFor(report: Report): InTotoStatement;
41
+ /** Writes <basename>.intoto.json and <basename>.predicate.json next to the report. */
42
+ export declare function writeStatement(reportPath: string): Promise<{
43
+ statement: string;
44
+ predicate: string;
45
+ }>;
46
+ export interface DsseEnvelope {
47
+ payloadType: typeof PAYLOAD_TYPE;
48
+ payload: string;
49
+ signatures: {
50
+ keyid: string;
51
+ sig: string;
52
+ }[];
53
+ }
54
+ /**
55
+ * Signs the statement with an SSH key (ssh-keygen -Y sign, namespace "workproof"), keeps
56
+ * the detached .sig ssh-keygen wrote, and wraps both in a DSSE envelope as
57
+ * <basename>.dsse.json. The signature covers the statement bytes as written.
58
+ */
59
+ export declare function signLocal(statementPath: string, keyPath: string): Promise<{
60
+ signature: string;
61
+ envelope: string;
62
+ }>;
@@ -0,0 +1,72 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, writeFile, rm } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { checkReport } from "./verify.js";
5
+ /** ssh-keygen asks on stdin before overwriting a signature; stdin is closed so it can never wait. */
6
+ const run = (cmd, args) => new Promise((resolve, reject) => {
7
+ const child = execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => (err ? reject(new Error(`${cmd} ${args[0]} ${args[1]} failed: ${String(stderr || err.message).trim()}`)) : resolve(stdout)));
8
+ child.stdin?.end();
9
+ });
10
+ export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
11
+ export const PREDICATE_TYPE = "https://workproof.dev/attestation/v1";
12
+ export const PAYLOAD_TYPE = "application/vnd.in-toto+json";
13
+ /**
14
+ * The predicate says what was measured and under which environment, nothing more: no
15
+ * figures (they are behind the subject digest), no remote URL, no paths of any kind.
16
+ */
17
+ export function predicateFor(report) {
18
+ const { ignoreRevsFile: _ignore, ...params } = report.params;
19
+ return {
20
+ tool: { name: "workproof", version: report.version },
21
+ generatedAt: report.generatedAt,
22
+ params,
23
+ repositories: report.repositories.map((r) => ({
24
+ head: r.head,
25
+ fingerprint: r.fingerprint,
26
+ fingerprintKeyed: r.fingerprintKeyed === true,
27
+ identity: { names: r.identity.names },
28
+ environment: r.environment,
29
+ excluded: r.excluded,
30
+ })),
31
+ };
32
+ }
33
+ /** An in-toto v1 Statement whose subject is the report's own hash. */
34
+ export function statementFor(report) {
35
+ const integrity = checkReport(report);
36
+ if (!integrity.ok)
37
+ throw new Error(`refusing to attest a report that does not check: ${integrity.problems[0]}`);
38
+ return {
39
+ _type: STATEMENT_TYPE,
40
+ subject: [{ name: "workproof-report", digest: { sha256: report.hash } }],
41
+ predicateType: PREDICATE_TYPE,
42
+ predicate: predicateFor(report),
43
+ };
44
+ }
45
+ const stem = (reportPath) => join(dirname(reportPath), basename(reportPath).replace(/\.json$/, ""));
46
+ /** Writes <basename>.intoto.json and <basename>.predicate.json next to the report. */
47
+ export async function writeStatement(reportPath) {
48
+ const report = JSON.parse(await readFile(reportPath, "utf8"));
49
+ const statement = statementFor(report);
50
+ const paths = { statement: `${stem(reportPath)}.intoto.json`, predicate: `${stem(reportPath)}.predicate.json` };
51
+ await writeFile(paths.statement, JSON.stringify(statement, null, 2) + "\n");
52
+ await writeFile(paths.predicate, JSON.stringify(statement.predicate, null, 2) + "\n");
53
+ return paths;
54
+ }
55
+ /**
56
+ * Signs the statement with an SSH key (ssh-keygen -Y sign, namespace "workproof"), keeps
57
+ * the detached .sig ssh-keygen wrote, and wraps both in a DSSE envelope as
58
+ * <basename>.dsse.json. The signature covers the statement bytes as written.
59
+ */
60
+ export async function signLocal(statementPath, keyPath) {
61
+ const signature = `${statementPath}.sig`;
62
+ await rm(signature, { force: true });
63
+ await run("ssh-keygen", ["-Y", "sign", "-f", keyPath, "-n", "workproof", statementPath]);
64
+ const envelope = {
65
+ payloadType: PAYLOAD_TYPE,
66
+ payload: (await readFile(statementPath)).toString("base64"),
67
+ signatures: [{ keyid: "ssh", sig: (await readFile(signature)).toString("base64") }],
68
+ };
69
+ const envelopePath = statementPath.replace(/\.intoto\.json$/, ".dsse.json");
70
+ await writeFile(envelopePath, JSON.stringify(envelope, null, 2) + "\n");
71
+ return { signature, envelope: envelopePath };
72
+ }
@@ -0,0 +1,13 @@
1
+ import type { Report } from "./report.js";
2
+ /** shields.io endpoint document: https://shields.io/badges/endpoint-badge */
3
+ export interface Badge {
4
+ schemaVersion: 1;
5
+ label: string;
6
+ message: string;
7
+ color: string;
8
+ }
9
+ /**
10
+ * A badge for the first repository in the report. It is a claim, not evidence:
11
+ * the JSON report next to it is what a reader verifies.
12
+ */
13
+ export declare function badgeFor(report: Report): Badge;
@@ -0,0 +1,20 @@
1
+ const pct = (x) => `${(x * 100).toFixed(1)}%`;
2
+ /**
3
+ * A badge for the first repository in the report. It is a claim, not evidence:
4
+ * the JSON report next to it is what a reader verifies.
5
+ */
6
+ export function badgeFor(report) {
7
+ const repo = report.repositories[0];
8
+ if (!repo)
9
+ throw new Error("the report has no repositories");
10
+ const surviving = repo.figures.find((f) => f.id === "survivingLines");
11
+ const tenure = repo.figures.find((f) => f.id === "tenure");
12
+ if (!surviving || !tenure)
13
+ throw new Error("the report has no surviving-lines or tenure figure");
14
+ return {
15
+ schemaVersion: 1,
16
+ label: "workproof",
17
+ message: `${pct(surviving.value.share)} surviving lines · ${Number(tenure.value.days).toLocaleString("en-US")} days`,
18
+ color: "1f3fbf",
19
+ };
20
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * RFC 8785 (JSON Canonicalization Scheme) serialisation, without a dependency.
3
+ *
4
+ * Object keys are sorted by UTF-16 code units, numbers use the shortest round-trip
5
+ * form JSON.stringify already produces, strings are escaped as JSON.stringify does,
6
+ * and there is no whitespace. Properties whose value is undefined are skipped, as
7
+ * JSON.stringify skips them. Non-finite numbers have no JSON form and throw.
8
+ */
9
+ export declare function canonicalize(value: unknown): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * RFC 8785 (JSON Canonicalization Scheme) serialisation, without a dependency.
3
+ *
4
+ * Object keys are sorted by UTF-16 code units, numbers use the shortest round-trip
5
+ * form JSON.stringify already produces, strings are escaped as JSON.stringify does,
6
+ * and there is no whitespace. Properties whose value is undefined are skipped, as
7
+ * JSON.stringify skips them. Non-finite numbers have no JSON form and throw.
8
+ */
9
+ export function canonicalize(value) {
10
+ if (value === null || typeof value === "boolean" || typeof value === "string")
11
+ return JSON.stringify(value);
12
+ if (typeof value === "number") {
13
+ if (!Number.isFinite(value))
14
+ throw new Error(`cannot canonicalise a non-finite number (${value})`);
15
+ return JSON.stringify(value);
16
+ }
17
+ if (Array.isArray(value))
18
+ return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`;
19
+ if (typeof value === "object") {
20
+ const record = value;
21
+ const keys = Object.keys(record)
22
+ .filter((k) => record[k] !== undefined)
23
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
24
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(record[k])}`).join(",")}}`;
25
+ }
26
+ throw new Error(`cannot canonicalise a value of type ${typeof value}`);
27
+ }
package/dist/src/cli.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Params } from "./index.js";
3
+ type OutputFormat = "both" | "markdown" | "json";
3
4
  interface Cli {
4
5
  params: Params;
5
6
  repos: string[];
6
7
  out: string;
8
+ format: OutputFormat;
7
9
  json: boolean;
8
10
  doNarrate: boolean;
11
+ badge: boolean;
9
12
  verifyFile: string | undefined;
13
+ checkFile: string | undefined;
14
+ attestFile: string | undefined;
15
+ localKey: string | undefined;
10
16
  }
11
17
  export declare function parse(argv: string[]): Cli;
12
18
  export {};
package/dist/src/cli.js CHANGED
@@ -3,9 +3,11 @@ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { createRequire } from "node:module";
6
- import { analyseRepo, buildReport, renderMarkdown, verifyReport, narrate } from "./index.js";
6
+ import { analyseRepo, buildReport, renderMarkdown, verifyReport, checkReport, narrate, badgeFor, newFingerprintKey, writeStatement, signLocal } from "./index.js";
7
7
  const HELP = `usage: workproof [options] [--repo <dir>]...
8
- workproof verify <report.json> [--repo <dir>]...
8
+ workproof check <report.json>
9
+ workproof verify <report.json> [--repo <dir>]... [--fingerprint-key <hex>]
10
+ workproof attest <report.json> [--local <ssh-key>]
9
11
 
10
12
  Turn a git repository into a verifiable engineering report for one author, without showing code.
11
13
 
@@ -15,20 +17,61 @@ Turn a git repository into a verifiable engineering report for one author, witho
15
17
  --sample <n> blame every n-th file (default: 1, or 7 for very large repositories)
16
18
  --max-commits <n> read only the newest n commits (escape hatch for enormous histories)
17
19
  --depth <n> directory depth for ownership (default: 2)
20
+ --no-exclusions count bot commits and generated, vendored, lock and snapshot files
21
+ --exclude <glob> also drop files matching the glob (repeatable)
22
+ --seed <text> salt for the blame file sample
23
+ --copies pass -C to git blame so copied lines follow their origin
24
+ --ignore-revs-file <f> blame ignore-revs file (default: .git-blame-ignore-revs at the root)
25
+ --fingerprint-key <hex> reuse a fingerprint key so two reports of one repository match
18
26
  --paths include directory paths in the report (off by default)
19
27
  --emails include author emails in the report (off by default)
20
28
  --narrate append a model-written paragraph; needs WORKPROOF_API_URL, WORKPROOF_API_KEY, WORKPROOF_MODEL
29
+ --badge also write <out>.badge.json, a shields.io endpoint document
21
30
  --out <basename> output basename (default: workproof-report)
22
- --json print the JSON to stdout instead of writing files
23
- -h, --help this text`;
31
+ --format <mode> output markdown, json, or both (default: both)
32
+ --json print the JSON to stdout instead of writing files (legacy alias)
33
+ -h, --help this text
34
+ --version print the version
35
+
36
+ check validates the document and recomputes its hash offline; verify does that, then
37
+ compares the fingerprint and HEAD and recomputes every figure in the repository; attest
38
+ writes an in-toto statement whose subject is the report hash, and with --local signs it
39
+ with ssh-keygen -Y sign (namespace workproof) into a DSSE envelope.`;
24
40
  export function parse(argv) {
25
- const params = { depth: 2, threshold: 0.5, minCommits: 5, paths: false, emails: false };
41
+ const params = { depth: 2, threshold: 0.5, minCommits: 5, paths: false, emails: false, exclusions: true, exclude: [], seed: "", copies: false };
26
42
  const repos = [];
27
43
  const authors = [];
28
44
  let out = "workproof-report";
45
+ let format = "both";
29
46
  let json = false;
30
47
  let doNarrate = false;
48
+ let badge = false;
31
49
  let verifyFile;
50
+ let checkFile;
51
+ let attestFile;
52
+ let localKey;
53
+ if (argv[0] === "attest") {
54
+ attestFile = argv[1];
55
+ if (!attestFile)
56
+ throw new Error("attest needs a report.json");
57
+ argv = argv.slice(2);
58
+ if (argv[0] === "--local") {
59
+ localKey = argv[1];
60
+ if (!localKey)
61
+ throw new Error("--local needs an ssh private key path");
62
+ argv = argv.slice(2);
63
+ }
64
+ if (argv.length)
65
+ throw new Error(`unknown option ${argv[0]} (attest takes only --local <ssh-key>)`);
66
+ }
67
+ if (argv[0] === "check") {
68
+ checkFile = argv[1];
69
+ if (!checkFile)
70
+ throw new Error("check needs a report.json");
71
+ if (argv.length > 2)
72
+ throw new Error("check takes only the report; it never reads a repository");
73
+ argv = [];
74
+ }
32
75
  if (argv[0] === "verify") {
33
76
  verifyFile = argv[1];
34
77
  if (!verifyFile)
@@ -60,16 +103,38 @@ export function parse(argv) {
60
103
  }
61
104
  else if (a === "--depth")
62
105
  params.depth = Number(next());
106
+ else if (a === "--no-exclusions")
107
+ params.exclusions = false;
108
+ else if (a === "--exclude")
109
+ params.exclude.push(next());
110
+ else if (a === "--seed")
111
+ params.seed = next();
112
+ else if (a === "--copies")
113
+ params.copies = true;
114
+ else if (a === "--ignore-revs-file")
115
+ params.ignoreRevsFile = next();
116
+ else if (a === "--fingerprint-key")
117
+ params.fingerprintKey = next();
63
118
  else if (a === "--paths")
64
119
  params.paths = true;
65
120
  else if (a === "--emails")
66
121
  params.emails = true;
67
122
  else if (a === "--narrate")
68
123
  doNarrate = true;
124
+ else if (a === "--badge")
125
+ badge = true;
69
126
  else if (a === "--out")
70
127
  out = next();
71
- else if (a === "--json")
128
+ else if (a === "--format") {
129
+ const value = next();
130
+ if (value !== "both" && value !== "markdown" && value !== "json")
131
+ throw new Error("--format must be markdown, json, or both");
132
+ format = value;
133
+ }
134
+ else if (a === "--json") {
72
135
  json = true;
136
+ format = "json";
137
+ }
73
138
  else if (a === "-h" || a === "--help") {
74
139
  console.log(HELP);
75
140
  process.exit(0);
@@ -81,24 +146,75 @@ export function parse(argv) {
81
146
  params.author = authors;
82
147
  if (!repos.length)
83
148
  repos.push(process.cwd());
84
- return { params, repos, out, json, doNarrate, verifyFile };
149
+ return { params, repos, out, format, json, doNarrate, badge, verifyFile, checkFile, attestFile, localKey };
85
150
  }
86
151
  async function main() {
152
+ if (process.argv.includes("--version")) {
153
+ console.log(createRequire(import.meta.url)("../../package.json").version);
154
+ return;
155
+ }
87
156
  const started = Date.now();
88
- const { params, repos, out, json, doNarrate, verifyFile } = parse(process.argv.slice(2));
157
+ const { params, repos, out, format, json, doNarrate, badge, verifyFile, checkFile, attestFile, localKey } = parse(process.argv.slice(2));
89
158
  const progress = (m) => process.stderr.write(`${m}\n`);
159
+ const printIntegrity = (i) => {
160
+ const schemaProblems = i.problems.filter((p) => !p.startsWith("hash mismatch"));
161
+ console.log(schemaProblems.length ? `schema: ${schemaProblems.length} problem${schemaProblems.length === 1 ? "" : "s"}` : "schema ok");
162
+ for (const p of schemaProblems)
163
+ console.log(` ${p}`);
164
+ if (!schemaProblems.length)
165
+ console.log(i.ok ? `hash ok ${i.hash.computed}` : i.problems.find((p) => p.startsWith("hash mismatch")));
166
+ };
167
+ if (attestFile) {
168
+ const { statement, predicate } = await writeStatement(attestFile);
169
+ const files = [statement, predicate];
170
+ if (localKey) {
171
+ const { signature, envelope } = await signLocal(statement, localKey);
172
+ files.push(signature, envelope);
173
+ }
174
+ console.log(`wrote ${files.slice(0, -1).join(", ")} and ${files[files.length - 1]}`);
175
+ return;
176
+ }
177
+ if (checkFile) {
178
+ const result = checkReport(JSON.parse(await readFile(checkFile, "utf8")));
179
+ printIntegrity(result);
180
+ process.exit(result.ok ? 0 : 1);
181
+ }
90
182
  if (verifyFile) {
91
183
  const report = JSON.parse(await readFile(verifyFile, "utf8"));
92
- const result = await verifyReport(report, repos);
184
+ const result = await verifyReport(report, repos, params.fingerprintKey ? { fingerprintKey: params.fingerprintKey } : {});
185
+ printIntegrity(result.integrity);
186
+ if (!result.integrity.ok) {
187
+ console.log("the report was edited or damaged after it was written; figures were not recomputed");
188
+ process.exit(1);
189
+ }
190
+ for (const f of result.fingerprints) {
191
+ if (!f.compared)
192
+ console.log(`${f.repo}: fingerprint not compared (pass --fingerprint-key)`);
193
+ else if (!f.match)
194
+ console.log(`${f.repo}: fingerprint differs; this is a different repository, figures were not recomputed`);
195
+ else
196
+ console.log(`${f.repo}: fingerprint ok`);
197
+ }
93
198
  for (const h of result.headMoved)
94
199
  console.log(`HEAD moved: ${h}`);
95
200
  for (const r of result.rows)
96
201
  if (!r.match)
97
202
  console.log(`mismatch ${r.repo}/${r.figure}\n report: ${r.expected}\n repository: ${r.actual}`);
98
- console.log(result.ok ? "all figures reproduce" : `${result.rows.filter((r) => !r.match).length} figures differ`);
203
+ const differ = result.rows.filter((r) => !r.match).length;
204
+ if (result.fingerprints.some((f) => f.compared && !f.match))
205
+ process.exit(1);
206
+ console.log(result.ok ? "all figures reproduce" : `${differ} figures differ`);
207
+ console.log(result.ok
208
+ ? "\nWhat this proves: every figure in the report was recomputed from this repository just now and came out the same, and the document has not been edited since it was written.\nWhat it does not prove: that the repository itself is honest history, that the figures measure anything worth measuring, or that the work was good. A repository whose history was rewritten before the report was made reproduces perfectly."
209
+ : "\nA figure that differs is not proof of dishonesty. HEAD moves, and every figure except tenure is computed at HEAD; check the HEAD line above before concluding anything.");
99
210
  process.exit(result.ok ? 0 : 1);
100
211
  }
101
212
  const version = createRequire(import.meta.url)("../../package.json").version;
213
+ if (!params.fingerprintKey) {
214
+ // One key per report, so the repositories in a combined report share it; printed once, stored nowhere.
215
+ params.fingerprintKey = newFingerprintKey();
216
+ progress(`fingerprint key ${params.fingerprintKey} (keep it to compare reports or to verify the fingerprint; it is not stored)`);
217
+ }
102
218
  const repositories = [];
103
219
  for (const dir of repos)
104
220
  repositories.push(await analyseRepo(dir, params, { progress }));
@@ -112,13 +228,24 @@ async function main() {
112
228
  throw new Error("--narrate needs WORKPROOF_API_URL, WORKPROOF_API_KEY and WORKPROOF_MODEL");
113
229
  narrative = await narrate(report, { url, key, model });
114
230
  }
115
- if (json) {
231
+ if (format === "json") {
116
232
  console.log(JSON.stringify(report, null, 2));
117
233
  return;
118
234
  }
119
- await writeFile(`${out}.json`, JSON.stringify(report, null, 2));
120
- await writeFile(`${out}.md`, renderMarkdown(report, narrative));
121
- console.log(`wrote ${out}.md and ${out}.json in ${((Date.now() - started) / 1000).toFixed(1)}s`);
235
+ const written = [];
236
+ if (format === "markdown" || format === "both") {
237
+ await writeFile(`${out}.md`, renderMarkdown(report, narrative));
238
+ written.push(`${out}.md`);
239
+ }
240
+ if (format === "both") {
241
+ await writeFile(`${out}.json`, JSON.stringify(report, null, 2));
242
+ written.push(`${out}.json`);
243
+ }
244
+ if (badge) {
245
+ await writeFile(`${out}.badge.json`, JSON.stringify(badgeFor(report), null, 2));
246
+ written.push(`${out}.badge.json`);
247
+ }
248
+ console.log(`wrote ${written.join(" and ")} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
122
249
  }
123
250
  const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
124
251
  if (entry === import.meta.url || entry.endsWith("/workproof")) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What leaves every denominator before a figure is computed.
3
+ *
4
+ * Bots are recognised by the two patterns GitHub uses for app identities, nothing
5
+ * cleverer: a name ending in "[bot]" or the "<id>+<name>[bot]@users.noreply.github.com"
6
+ * address. Generated, vendored, lock and snapshot files follow the subset of
7
+ * github-linguist's generated.rb and vendor.yml that moves line counts, plus whatever a
8
+ * repository marks with linguist-generated or linguist-vendored in .gitattributes.
9
+ */
10
+ export declare const isBot: (c: {
11
+ name: string;
12
+ email: string;
13
+ }) => boolean;
14
+ /** True for paths the built-in lists treat as generated, vendored, lock or snapshot files. */
15
+ export declare function isExcludedPath(path: string): boolean;
16
+ export interface PathAttributes {
17
+ generated: boolean;
18
+ vendored: boolean;
19
+ }
20
+ /**
21
+ * The set of paths to drop, from the built-in lists, the repository's .gitattributes,
22
+ * and the user's --exclude globs (already compiled to RegExp).
23
+ */
24
+ export declare function excludedSet(paths: Iterable<string>, attrs: Map<string, PathAttributes>, extra: RegExp[]): Set<string>;